Compare commits
401 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7821451c7 | |||
| 9056b43696 | |||
| cdf54e9eff | |||
| 7a49e9646c | |||
| 2189f5e7c4 | |||
| 2822b4c159 | |||
| 3dd2591709 | |||
| ab2145ffe9 | |||
| 3ee880d1dd | |||
| 586e103161 | |||
| 5776bf2a51 | |||
| ceb442ebf1 | |||
| e6a2bdc65f | |||
| c9205adbab | |||
| 29d56daab3 | |||
| 4d7ac5e619 | |||
| fb3686fef4 | |||
| ed14ef0cd9 | |||
| fd90f90cbb | |||
| b6cee104b9 | |||
| 5c6df95734 | |||
| 8941aca968 | |||
| e1348f782e | |||
| b652976784 | |||
| 530c0681d0 | |||
| e38f57f823 | |||
| f3b9eb9f73 | |||
| bd7be9590a | |||
| e1fa43c9f0 | |||
| ccb0d9ec71 | |||
| eca4a5ba77 | |||
| 6dd29c571f | |||
| 28f1e2b517 | |||
| 21374b2cb4 | |||
| ada87468cc | |||
| 0a4b488d69 | |||
| aa257b34ec | |||
| c48e6c7123 | |||
| 827bc4b836 | |||
| c7d115f873 | |||
| 45c585a27d | |||
| dc3fe02767 | |||
| a1ef06510e | |||
| 56002c68ca | |||
| 30bd73f8f9 | |||
| 9d8a30f678 | |||
| 2b0b99d598 | |||
| 8551852c9d | |||
| 7ade0eaeb1 | |||
| debdbf770b | |||
| 01976685e8 | |||
| bac5d71480 | |||
| 0ad655d1cf | |||
| a526e301da | |||
| 9c16c6df40 | |||
| 3220e9482b | |||
| 9a033d7f91 | |||
| 2329458a84 | |||
| c613a7aedd | |||
| d4d502f418 | |||
| 7f37f16c7b | |||
| a67d007435 | |||
| a5c6645d2d | |||
| cbe50a0232 | |||
| a48ac48202 | |||
| 0a7aaca6e5 | |||
| 6e41ea3b42 | |||
| 75ada621d9 | |||
| 12d578ff57 | |||
| 3486b7f503 | |||
| 0aed5e0f31 | |||
| 34c40980e3 | |||
| b13eb6012c | |||
| ecc3284a94 | |||
| b10c8ff182 | |||
| d96e222a15 | |||
| ec63533108 | |||
| 6f74366dd9 | |||
| 4b5825790a | |||
| c257e61fa7 | |||
| f7391c0e0b | |||
| dce3d5b411 | |||
| a2490da3b4 | |||
| 0bd4877dd3 | |||
| 1aacc0073f | |||
| 6195ae6901 | |||
| 6f2d80b99e | |||
| a8e7901eac | |||
| d4ae9d9611 | |||
| d82a3cffe8 | |||
| 05e189b938 | |||
| d32d0b17d0 | |||
| 3a8282255c | |||
| 79e97fae09 | |||
| 9b93881663 | |||
| 21df47eccb | |||
| 1d87315426 | |||
| 4a1e21e820 | |||
| a8181c45d0 | |||
| 84ca17ebc4 | |||
| 4db0e8870d | |||
| 310993d57c | |||
| d624b93d8c | |||
| 872d319220 | |||
| cd44ae6bc0 | |||
| deb59b314b | |||
| 4c75d4f559 | |||
| c5bc900212 | |||
| 8bd2bca879 | |||
| 27283384bf | |||
| 9901635008 | |||
| 1f5ce2546c | |||
| 2f0adcce7c | |||
| 7bfab65042 | |||
| b59eeeca81 | |||
| 81e42f24c8 | |||
| d4a928b682 | |||
| da27054a9b | |||
| 3c54cd27fe | |||
| 8fe8525b06 | |||
| 9169cd5d1f | |||
| 490b8554e2 | |||
| 97748dfd34 | |||
| 0d8b320f31 | |||
| 32b0cef65d | |||
| abcb51c0e2 | |||
| 8d02645e26 | |||
| 6ab05471b2 | |||
| f82adab05d | |||
| 1291a0e932 | |||
| a2d40c5cbf | |||
| 17954e0504 | |||
| 2e5e6c9ad3 | |||
| c1e9143483 | |||
| 774f7d2dbe | |||
| de9eab1e4e | |||
| 9b1925d6de | |||
| a6ad1bdcdb | |||
| f9c8bbc4cc | |||
| 3634440c8b | |||
| 488ce5750d | |||
| c963673a19 | |||
| 6e2589e125 | |||
| d980fdf96d | |||
| 89fe5b8937 | |||
| 93bc669f24 | |||
| 0602c1b59d | |||
| 6e2716d957 | |||
| 9c3ec58246 | |||
| d8a81879b1 | |||
| c054bc7bc7 | |||
| c46e7b98e0 | |||
| 65762e8645 | |||
| 689ac34946 | |||
| daf35f6e41 | |||
| 58a5c470bd | |||
| aecddf6fb5 | |||
| f702513a64 | |||
| 3126ad2380 | |||
| d947a951ad | |||
| 51fffc0ae1 | |||
| 273cf1094d | |||
| 403946bac5 | |||
| cb81fd3315 | |||
| 95db4e4dcf | |||
| c5fb019702 | |||
| c907779b3c | |||
| c1b33e17c8 | |||
| 5fed2f4182 | |||
| b2f62e12c7 | |||
| 79571cc5b3 | |||
| 3e6b947893 | |||
| cc59035c62 | |||
| 9b1615480f | |||
| ad59299581 | |||
| 4ac8651cc8 | |||
| a11be64d94 | |||
| a32c620b4e | |||
| 7a8fbe3ee5 | |||
| 13480d528a | |||
| 7ddaf135b4 | |||
| 848ac15ef0 | |||
| ae1f97eb08 | |||
| a2fa2a6b96 | |||
| 2d9ff34ded | |||
| d1a85659ba | |||
| d832e6e364 | |||
| 08a5c808f8 | |||
| 66cfe9ee45 | |||
| c2b14e4f07 | |||
| 628dd47772 | |||
| f8612ee20e | |||
| 172bebe24a | |||
| 3c45641ef4 | |||
| 5eb6af1ab6 | |||
| c1b48058d5 | |||
| 33ebeec2ac | |||
| a3e5ff9f4a | |||
| 203ef9dd44 | |||
| bdfb8f9dc6 | |||
| 865fabce98 | |||
| e530e38721 | |||
| d98ae9cdbf | |||
| 5100b76ad3 | |||
| 8328af802f | |||
| 87914291c6 | |||
| 0c7daef65e | |||
| 09da778d3b | |||
| 2ad64bbca7 | |||
| 247b94f3b3 | |||
| 8078ad5609 | |||
| 7fd70ac0d9 | |||
| dbdaff2ada | |||
| 3e53e368a4 | |||
| 6a7c037ea8 | |||
| 2414441efa | |||
| 14deb86a7a | |||
| 572c3b082e | |||
| d2b466df93 | |||
| 3f11465a7e | |||
| ece9a37af4 | |||
| 7f4cf8bdcd | |||
| d6538aac50 | |||
| 3a47fccf16 | |||
| c659eaeead | |||
| 37c7a37bdf | |||
| d3f23544cc | |||
| 9d5ea22806 | |||
| 1388d0e514 | |||
| e3412fac46 | |||
| e43c0b1e2e | |||
| b13a5ae1ae | |||
| 8c52848212 | |||
| 053007e7ea | |||
| 9889cb07d4 | |||
| 142b144318 | |||
| a47e53ff2d | |||
| 25bfe4f0fa | |||
| 8b88cd3cf6 | |||
| 748bf43847 | |||
| 586c536c46 | |||
| 4a84a782db | |||
| e2dbc0e1cf | |||
| 2b434de40b | |||
| a51c174021 | |||
| c1373174ca | |||
| c3f0ecf7d5 | |||
| 2f2fdb1809 | |||
| 95a123532b | |||
| 2cc7c7bcaf | |||
| b314b98dd6 | |||
| 5ec79e9612 | |||
| 9e89972008 | |||
| 05864d001a | |||
| f9fc81ce71 | |||
| 66a23cc99b | |||
| aa2d724a13 | |||
| 67f840c0ec | |||
| f2e545ff09 | |||
| 11142bc96a | |||
| e3d01bc6aa | |||
| d3fc1c602a | |||
| 926ad380f3 | |||
| b55a9bae43 | |||
| f8c46d7a11 | |||
| 75ca14c900 | |||
| 163712471c | |||
| 974cdcccc9 | |||
| bbe53a4c69 | |||
| 88d9e783b8 | |||
| 854f9aca23 | |||
| 69dde41d9c | |||
| e159e5bb6d | |||
| fce3c81029 | |||
| b14717eddb | |||
| 5891014ff6 | |||
| b51535bfa0 | |||
| 8476edd18f | |||
| e528cdb36d | |||
| 8696c698ed | |||
| c236caefad | |||
| f9de5282c9 | |||
| a4c2895c68 | |||
| e33ee800bc | |||
| 24aa80840c | |||
| d9aa6258cd | |||
| 941e6ee4e6 | |||
| ffab9d3aaa | |||
| 4d16e1ab83 | |||
| 6781685252 | |||
| 88bdf87e95 | |||
| 6b3d98bd66 | |||
| ec7ceb2352 | |||
| c62c38b136 | |||
| 33bf59f353 | |||
| b4d3c4833c | |||
| 44b54f6c32 | |||
| 57f7af3141 | |||
| 23dbd9112a | |||
| 44c8103600 | |||
| d2441b345b | |||
| 2c828c8778 | |||
| 8097f0e5fb | |||
| 14644d0cb3 | |||
| 4c89a20bbe | |||
| 6a2285ef72 | |||
| 37a791f113 | |||
| 06f820d355 | |||
| 3a2571ccc6 | |||
| dd614e6a6a | |||
| f5595b3477 | |||
| e9c34df51e | |||
| 82b9964a61 | |||
| a0cb5cc307 | |||
| 0b26ef51a2 | |||
| da3cc5f997 | |||
| 96356eb804 | |||
| 80067a212c | |||
| d962d7952b | |||
| ae4847ce50 | |||
| a07a2de786 | |||
| 96ce34c7f1 | |||
| c6fa1acf66 | |||
| a7c29c4a85 | |||
| 91364385c3 | |||
| d958722e63 | |||
| d3a19ebfaa | |||
| c17883bdb8 | |||
| 0b90b0206b | |||
| 8f958ef6c7 | |||
| 2118fa483b | |||
| 2bb9c7738a | |||
| 7ed55b00a6 | |||
| 4feb051177 | |||
| 27bce0d334 | |||
| ddf50724f0 | |||
| d07bd75d07 | |||
| d59ba03cc6 | |||
| 04112110f7 | |||
| d835cb5e6a | |||
| 6e5a6b5d91 | |||
| 270cb51acc | |||
| 579c78b2ad | |||
| 470cdd1c76 | |||
| 2abbae38d9 | |||
| 46dc8d9e12 | |||
| 59cbb9d740 | |||
| c50c9bec7e | |||
| 14ebcb1165 | |||
| 3728ad02e6 | |||
| ba9e1f5375 | |||
| 598c5f90ea | |||
| 6e1a195615 | |||
| 452848f14f | |||
| 4ccc123209 | |||
| a987449789 | |||
| ba9ff0964b | |||
| 3cd64fb0af | |||
| 6e8e6fe243 | |||
| 476f1bade2 | |||
| cf1d9ad53f | |||
| 680ff86202 | |||
| 9797fcd95a | |||
| 5d3841d6a7 | |||
| 9e5de53ad4 | |||
| 13cdbc565c | |||
| 5ea4b0f73d | |||
| f3e262bd3a | |||
| 96b8288c5b | |||
| a85590f5fb | |||
| 5a93bdd0a6 | |||
| be582f4db7 | |||
| 01a174f9e3 | |||
| ecc306079b | |||
| 251ea43e33 | |||
| 37b8fc6752 | |||
| 17b986c21d | |||
| d6b3dbc9f9 | |||
| 1f41478d53 | |||
| bd71520cb5 | |||
| 6276d135e4 | |||
| 3abdbc2d88 | |||
| 241f234a82 | |||
| 28ee5b6881 | |||
| d92790f0af | |||
| e18d0592d6 | |||
| 11802fc38a | |||
| b2ae35d597 | |||
| ec37a8befe | |||
| 804dd550a2 | |||
| 736d76f457 | |||
| 84734d7304 | |||
| e19b99a2bc | |||
| 577334cbaa | |||
| 35b615dc9f | |||
| ef8e9a3ccf | |||
| 27d5544f8b | |||
| e98111bf00 | |||
| c8c68f1898 | |||
| f47ccbec51 | |||
| ba53bc05a4 |
@@ -9,14 +9,14 @@ This skill guides you through publishing a new release of the app. It handles ve
|
||||
|
||||
## Overview
|
||||
|
||||
- **Version format**: Semantic versioning (X.Y.Z), starting from 2.0.0
|
||||
- **Version format**: Marketing version (X.Y.Z), starting from 2.0.0. **This is NOT semver.** Version numbers are chosen based on how the release looks to end users, not based on API compatibility or breaking changes. Think of it like an app store version -- the number reflects the perceived significance of the update to a regular user.
|
||||
- **Version source of truth**: `package.json` `version` field
|
||||
- **Changelog**: `CHANGELOG.md` in repo root, using [Keep a Changelog](https://keepachangelog.com/) format
|
||||
- **Version bumping**: Marketing-driven (not strict semver)
|
||||
- **Patch (Z)**: Bug fixes, minor tweaks, dependency updates, small UI adjustments
|
||||
- **Minor (Y)**: New user-facing features, significant UI changes, new pages/screens
|
||||
- **Version bumping**:
|
||||
- **Patch (Z)**: Most releases. Bug fixes, tweaks, internal improvements, anything a user wouldn't specifically notice or seek out.
|
||||
- **Minor (Y)**: Releases with headline features -- things worth announcing. A user should be able to look at the minor bump and think "oh, something new happened."
|
||||
- **Major (X)**: Only when the user explicitly requests it (milestones, rebrands, major redesigns)
|
||||
- **CI trigger**: Pushing a semver tag (`v2.1.0`) triggers the CI pipeline to build APKs, create a GitLab release, and publish to Zapstore
|
||||
- **CI trigger**: Pushing a version tag (`v2.1.0`) triggers the CI pipeline to build APKs, create a GitLab release, and publish to Zapstore
|
||||
|
||||
## Release Procedure
|
||||
|
||||
@@ -69,11 +69,11 @@ Analyze the commits from Step 3 and determine the appropriate bump level:
|
||||
|
||||
| Bump | When to use | Example |
|
||||
|------|-------------|---------|
|
||||
| **Patch** | Bug fixes, minor tweaks, dependency updates, small UI polish | 2.0.0 -> 2.0.1 |
|
||||
| **Minor** | New user-facing features, new screens/pages, significant UI changes | 2.0.1 -> 2.1.0 |
|
||||
| **Patch** | Bug fixes, minor tweaks, dependency updates, small UI polish, internal tooling, developer-facing pages, CI/build changes, settings/admin screens | 2.0.0 -> 2.0.1 |
|
||||
| **Minor** | Significant new product features that change how users interact with the app -- the kind of thing you'd highlight in an app store update or announce on social media (e.g., new content type support, DM redesign, new social features, theme system overhaul) | 2.0.1 -> 2.1.0 |
|
||||
| **Major** | ONLY when the user explicitly instructs a major bump | 2.1.0 -> 3.0.0 |
|
||||
|
||||
**Default to patch** when in doubt. Choose minor if there are clearly new features. Never auto-bump major.
|
||||
**Default to patch** when in doubt. The bar for a minor bump is high -- ask yourself: "Would a regular user notice and care about this change?" If the answer is no, it's a patch. Internal pages (changelog, settings, about screens), infrastructure improvements, CI fixes, and developer tooling are always patch-level regardless of whether they technically add a new page or screen.
|
||||
|
||||
When bumping minor, reset patch to 0 (e.g., 2.0.3 -> 2.1.0).
|
||||
When bumping major, reset minor and patch to 0 (e.g., 2.3.1 -> 3.0.0).
|
||||
@@ -108,6 +108,9 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
|
||||
- Focus on what the user sees/experiences, not internal implementation details
|
||||
- Use the current date in YYYY-MM-DD format
|
||||
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
|
||||
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
|
||||
|
||||
### Step 6: Update Version in All Files
|
||||
|
||||
@@ -131,24 +134,44 @@ versionName "X.Y.Z"
|
||||
|
||||
#### 6c. `ios/App/App.xcodeproj/project.pbxproj`
|
||||
|
||||
Update `MARKETING_VERSION` in all 4 occurrences (2 Debug configs + 2 Release configs):
|
||||
Update `MARKETING_VERSION` in all occurrences (Debug + Release configs):
|
||||
|
||||
```
|
||||
MARKETING_VERSION = X.Y.Z;
|
||||
```
|
||||
|
||||
**Important:** There are exactly 4 lines containing `MARKETING_VERSION` in this file. All 4 must be updated to the same value. Use a replaceAll operation.
|
||||
**Important:** All lines containing `MARKETING_VERSION` must be updated to the same value. Use a replaceAll operation.
|
||||
|
||||
Do NOT change `CURRENT_PROJECT_VERSION` -- it stays at `1` (may be managed separately for App Store submissions in the future).
|
||||
|
||||
### Step 7: Commit the Release
|
||||
### Step 7: Copy Changelog to Public Directory
|
||||
|
||||
The changelog is served at runtime by the app from the `public/` directory. After updating `CHANGELOG.md`, copy it:
|
||||
|
||||
```bash
|
||||
git add package.json CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj
|
||||
cp CHANGELOG.md public/CHANGELOG.md
|
||||
```
|
||||
|
||||
### Step 8: Pull Latest Changes
|
||||
|
||||
Before committing the release, pull the latest changes from the remote to ensure the release commit sits on top of the latest code. This **must** happen before committing and tagging.
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
**CRITICAL**: Always use `git pull` (merge), NEVER `git pull --rebase`. Rebasing rewrites commit hashes, which would orphan any tag pointing to the original commit. Since version tags are often protected on the remote and cannot be deleted or updated, a broken tag cannot be easily fixed.
|
||||
|
||||
If there are merge conflicts with the pulled changes, resolve them before proceeding.
|
||||
|
||||
### Step 9: Commit the Release
|
||||
|
||||
```bash
|
||||
git add package.json CHANGELOG.md public/CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj
|
||||
git commit -m "release: vX.Y.Z"
|
||||
```
|
||||
|
||||
### Step 8: Tag the Release
|
||||
### Step 10: Tag the Release
|
||||
|
||||
```bash
|
||||
git tag vX.Y.Z
|
||||
@@ -156,18 +179,20 @@ git tag vX.Y.Z
|
||||
|
||||
The tag format is `v` followed by the semver version with no suffix. Examples: `v2.0.0`, `v2.1.0`, `v2.1.1`.
|
||||
|
||||
### Step 9: Push
|
||||
### Step 11: Push
|
||||
|
||||
```bash
|
||||
git push origin main --tags
|
||||
git push origin main vX.Y.Z
|
||||
```
|
||||
|
||||
**CRITICAL**: Push only the specific tag being released. NEVER use `--tags` -- that pushes ALL local tags, including stale or deleted ones.
|
||||
|
||||
This triggers the GitLab CI pipeline which will:
|
||||
1. Build a signed Android APK and AAB
|
||||
2. Create a GitLab Release with download links
|
||||
3. Publish the APK to Zapstore
|
||||
|
||||
### Step 10: Confirm
|
||||
### Step 12: Confirm
|
||||
|
||||
After pushing, inform the user:
|
||||
- The new version number
|
||||
@@ -180,8 +205,9 @@ After pushing, inform the user:
|
||||
|------|---------------|-------|
|
||||
| `package.json` | `version` field | Source of truth for the version |
|
||||
| `CHANGELOG.md` | Prepend new section | User-facing changelog |
|
||||
| `public/CHANGELOG.md` | Copy from `CHANGELOG.md` | Served at runtime by the app |
|
||||
| `android/app/build.gradle` | `versionName` on line 17 | `versionCode` is managed by CI |
|
||||
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (4 occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
|
||||
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (all occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
@@ -205,7 +231,7 @@ If you tagged the wrong version and haven't pushed yet:
|
||||
git tag -d vX.Y.Z # delete the local tag
|
||||
git reset --soft HEAD~1 # undo the commit but keep changes staged
|
||||
```
|
||||
Then redo steps 4-9 with the correct version.
|
||||
Then redo steps 4-10 with the correct version.
|
||||
|
||||
### Already pushed a bad release
|
||||
This requires manual intervention. Inform the user and suggest they delete the tag and release from GitLab manually, then re-run the release process.
|
||||
|
||||
@@ -162,23 +162,18 @@ release:
|
||||
if [ -z "$RELEASE_NOTES" ]; then
|
||||
RELEASE_NOTES="Ditto ${CI_COMMIT_TAG}"
|
||||
fi
|
||||
echo "RELEASE_NOTES<<ENDOFNOTES" >> release.env
|
||||
echo "$RELEASE_NOTES" >> release.env
|
||||
echo "ENDOFNOTES" >> release.env
|
||||
artifacts:
|
||||
reports:
|
||||
dotenv: release.env
|
||||
- echo "$RELEASE_NOTES" > release-notes.md
|
||||
release:
|
||||
tag_name: $CI_COMMIT_TAG
|
||||
name: $CI_COMMIT_TAG
|
||||
description: $RELEASE_NOTES
|
||||
description: './release-notes.md'
|
||||
assets:
|
||||
links:
|
||||
- name: "Ditto-${CI_COMMIT_TAG}.apk"
|
||||
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk"
|
||||
- name: Ditto-${CI_COMMIT_TAG}.apk
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk
|
||||
link_type: package
|
||||
- name: "Ditto-${CI_COMMIT_TAG}.aab"
|
||||
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab"
|
||||
- name: Ditto-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab
|
||||
link_type: package
|
||||
|
||||
publish-zapstore:
|
||||
|
||||
@@ -12,6 +12,7 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
|
||||
- **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality
|
||||
- **TanStack Query**: For data fetching, caching, and state management
|
||||
- **TypeScript**: For type-safe JavaScript development
|
||||
- **Capacitor**: Native iOS and Android shell wrapping the web app
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -1246,7 +1247,68 @@ If git is available in your environment (through a `shell` tool, or other git-sp
|
||||
|
||||
When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes.
|
||||
|
||||
**ALWAYS commit when you are finished making changes.**
|
||||
**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.**
|
||||
|
||||
## Capacitor Compatibility
|
||||
|
||||
The app runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
|
||||
|
||||
### What Doesn't Work in WKWebView (iOS)
|
||||
|
||||
- **`<a download>` file downloads** -- Programmatically creating an anchor element with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
|
||||
- **`<a target="_blank">` new tabs** -- Programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
|
||||
- **`window.open()`** -- May be blocked or behave unexpectedly without user gesture context.
|
||||
|
||||
### File Downloads and URL Opening
|
||||
|
||||
The project provides two utility functions in `src/lib/downloadFile.ts` that handle the web/native split automatically:
|
||||
|
||||
#### `downloadTextFile(filename, content)`
|
||||
|
||||
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
|
||||
|
||||
```typescript
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
|
||||
await downloadTextFile('backup.txt', fileContents);
|
||||
```
|
||||
|
||||
#### `openUrl(url)`
|
||||
|
||||
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
|
||||
|
||||
```typescript
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
await openUrl('https://example.com/image.jpg');
|
||||
```
|
||||
|
||||
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. Always use the utilities above. They handle the Capacitor/web split and will work correctly on all platforms.
|
||||
|
||||
### Detecting Native Platforms
|
||||
|
||||
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
|
||||
|
||||
```typescript
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// iOS or Android
|
||||
} else {
|
||||
// Web browser
|
||||
}
|
||||
```
|
||||
|
||||
### Installed Capacitor Plugins
|
||||
|
||||
- `@capacitor/app` -- App lifecycle events (deep links, back button)
|
||||
- `@capacitor/core` -- Core runtime and platform detection
|
||||
- `@capacitor/filesystem` -- Read/write files on the native filesystem
|
||||
- `@capacitor/local-notifications` -- Schedule local push notifications
|
||||
- `@capacitor/share` -- Native share sheet
|
||||
- `@capacitor/status-bar` -- Control the native status bar style
|
||||
|
||||
After adding or removing plugins, run `npx cap sync` to update the native projects.
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
|
||||
- Mobile header no longer shows double-layered backgrounds on notched devices
|
||||
- Pinned tabs stay properly positioned when scrolling on mobile
|
||||
- Signer approval toasts no longer fire in rapid succession on unstable connections
|
||||
- Toasts are easier to swipe away on mobile
|
||||
- Content warnings now blur thumbnails in the media grid
|
||||
|
||||
## [2.2.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen vines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber and NIP-46 users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
### Changed
|
||||
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
|
||||
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
|
||||
- Upgraded from React 18 to React 19
|
||||
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
|
||||
|
||||
### Fixed
|
||||
- Zapping Primal users no longer produces an error
|
||||
- Hashtag feeds now match case-insensitively for parity with search results
|
||||
- Mobile top bar arc no longer lingers on pages without tabs
|
||||
- Give Badge dialog and profile menu action handlers
|
||||
|
||||
## [2.1.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Emoji picker and shortcode autocomplete in zap comment box
|
||||
- Zap button on badge detail view
|
||||
- Theme descriptions now display on "updated their theme" posts and detail pages
|
||||
- Badge thumbnail previews in award notifications
|
||||
- Letter notifications with envelope card preview
|
||||
- Kind-specific labels in notification text instead of generic "post"
|
||||
|
||||
### Fixed
|
||||
- Compose modal no longer closes when dismissing emoji picker on mobile
|
||||
- Compose preview overflow is now scrollable in modal
|
||||
- Toast notifications swipe up to dismiss on mobile instead of sideways
|
||||
- File downloads and URL opening work correctly on iOS
|
||||
- Badges page no longer shows infinite skeleton when logged out
|
||||
|
||||
## [2.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
|
||||
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
|
||||
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
|
||||
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
|
||||
- Letters page added to the sidebar with a custom mailbox icon
|
||||
|
||||
## [2.0.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Tap the version number in settings to see what's new
|
||||
|
||||
## [2.0.0] - 2026-03-26
|
||||
|
||||
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
| 36767 | Theme Definition | Shareable, named custom UI theme |
|
||||
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
|
||||
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
|
||||
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.0.0"
|
||||
versionName "2.2.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -10,7 +10,9 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
public void configure(PluginCall call) {
|
||||
String userPubkey = call.getString("userPubkey");
|
||||
String relayUrlsRaw = null;
|
||||
String enabledKindsRaw = null;
|
||||
String authorsRaw = null;
|
||||
|
||||
try {
|
||||
JSONArray relayUrls = call.getArray("relayUrls");
|
||||
@@ -35,14 +37,40 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
Log.w(TAG, "Failed to read relayUrls", e);
|
||||
}
|
||||
|
||||
try {
|
||||
JSONArray enabledKinds = call.getArray("enabledKinds");
|
||||
if (enabledKinds != null) {
|
||||
enabledKindsRaw = enabledKinds.toString();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to read enabledKinds", e);
|
||||
}
|
||||
|
||||
try {
|
||||
JSONArray authors = call.getArray("authors");
|
||||
if (authors != null) {
|
||||
authorsRaw = authors.toString();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to read authors", e);
|
||||
}
|
||||
|
||||
SharedPreferences prefs = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
|
||||
if (userPubkey != null && relayUrlsRaw != null) {
|
||||
prefs.edit()
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.putString("userPubkey", userPubkey)
|
||||
.putString("relayUrls", relayUrlsRaw)
|
||||
.apply();
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw);
|
||||
.putString("relayUrls", relayUrlsRaw);
|
||||
if (enabledKindsRaw != null) {
|
||||
editor.putString("enabledKinds", enabledKindsRaw);
|
||||
}
|
||||
if (authorsRaw != null) {
|
||||
editor.putString("authors", authorsRaw);
|
||||
} else {
|
||||
editor.remove("authors");
|
||||
}
|
||||
editor.apply();
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
|
||||
} else {
|
||||
// Clear config (user logged out)
|
||||
prefs.edit().clear().apply();
|
||||
|
||||
@@ -265,6 +265,7 @@ public class NostrPoller {
|
||||
}
|
||||
return "commented on your post";
|
||||
}
|
||||
case 8211: return "sent you a letter";
|
||||
default: return "mentioned you";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +243,8 @@ public class NotificationRelayService extends Service {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
String userPubkey = prefs.getString("userPubkey", null);
|
||||
String relayUrlsJson = prefs.getString("relayUrls", null);
|
||||
String enabledKindsJson = prefs.getString("enabledKinds", null);
|
||||
String authorsJson = prefs.getString("authors", null);
|
||||
|
||||
if (userPubkey == null || relayUrlsJson == null) {
|
||||
Log.d(TAG, "No config, skipping fetch");
|
||||
@@ -268,10 +270,17 @@ public class NotificationRelayService extends Service {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(relayUrls.get(relayIndex), userPubkey);
|
||||
List<Integer> enabledKinds = parseEnabledKinds(enabledKindsJson);
|
||||
if (enabledKinds.isEmpty()) {
|
||||
Log.d(TAG, "No enabled kinds, skipping fetch");
|
||||
releaseFetchWakeLock();
|
||||
return;
|
||||
}
|
||||
List<String> authors = parseAuthors(authorsJson);
|
||||
fetch(relayUrls.get(relayIndex), userPubkey, enabledKinds, authors);
|
||||
}
|
||||
|
||||
private void fetch(String relayUrl, String userPubkey) {
|
||||
private void fetch(String relayUrl, String userPubkey, List<Integer> enabledKinds, List<String> authors) {
|
||||
long since = poller.getLastSeenTimestamp();
|
||||
if (since == 0) {
|
||||
since = (System.currentTimeMillis() / 1000) - 300; // 5 min ago on first run
|
||||
@@ -284,7 +293,9 @@ public class NotificationRelayService extends Service {
|
||||
try {
|
||||
JSONObject filter = new JSONObject();
|
||||
JSONArray kinds = new JSONArray();
|
||||
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111);
|
||||
for (int kind : enabledKinds) {
|
||||
kinds.put(kind);
|
||||
}
|
||||
filter.put("kinds", kinds);
|
||||
JSONArray pTags = new JSONArray();
|
||||
pTags.put(userPubkey);
|
||||
@@ -292,6 +303,15 @@ public class NotificationRelayService extends Service {
|
||||
filter.put("since", since + 1);
|
||||
filter.put("limit", FETCH_LIMIT);
|
||||
|
||||
// When "only from people I follow" is enabled, restrict to those authors
|
||||
if (!authors.isEmpty()) {
|
||||
JSONArray authorsArr = new JSONArray();
|
||||
for (String author : authors) {
|
||||
authorsArr.put(author);
|
||||
}
|
||||
filter.put("authors", authorsArr);
|
||||
}
|
||||
|
||||
JSONArray req = new JSONArray();
|
||||
req.put("REQ");
|
||||
req.put(currentSubId);
|
||||
@@ -397,7 +417,8 @@ public class NotificationRelayService extends Service {
|
||||
}
|
||||
|
||||
Log.d(TAG, "Retrying in " + backoffMs + "ms on relay " + relayIndex);
|
||||
Runnable retry = () -> fetch(relayUrls.get(relayIndex), userPubkey);
|
||||
// Re-read config from prefs on retry so enabled kinds stay current.
|
||||
Runnable retry = this::runFetchCycle;
|
||||
handler.postDelayed(retry, backoffMs);
|
||||
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
||||
|
||||
@@ -515,6 +536,46 @@ public class NotificationRelayService extends Service {
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the authors filter from JSON. Returns an empty list when the value
|
||||
* is null or invalid (meaning no author restriction).
|
||||
*/
|
||||
private List<String> parseAuthors(String json) {
|
||||
List<String> authors = new ArrayList<>();
|
||||
if (json != null) {
|
||||
try {
|
||||
JSONArray arr = new JSONArray(json);
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
authors.add(arr.getString(i));
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse authors", e);
|
||||
}
|
||||
}
|
||||
return authors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the enabled notification kinds from JSON. Returns an empty list
|
||||
* when the value is null or invalid — the caller should skip polling
|
||||
* when the list is empty (the JS layer always provides kinds via
|
||||
* DittoNotification.configure in the same write as pubkey/relays).
|
||||
*/
|
||||
private List<Integer> parseEnabledKinds(String json) {
|
||||
List<Integer> kinds = new ArrayList<>();
|
||||
if (json != null) {
|
||||
try {
|
||||
JSONArray arr = new JSONArray(json);
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
kinds.add(arr.getInt(i));
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse enabled kinds", e);
|
||||
}
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
private Notification buildForegroundNotification() {
|
||||
Intent notificationIntent = new Intent(this, MainActivity.class);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(
|
||||
|
||||
@@ -5,8 +5,14 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# Blobbi Tag Schema
|
||||
|
||||
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
|
||||
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
|
||||
|
||||
## Overview
|
||||
|
||||
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
|
||||
- All valid tags and their purposes
|
||||
- Which tags are required vs optional
|
||||
- Which tags persist across stage transitions
|
||||
- Which tags should be removed during transitions
|
||||
- Deprecated tags that should be filtered out
|
||||
|
||||
---
|
||||
|
||||
## Tag Categories
|
||||
|
||||
### 1. System / Metadata Tags
|
||||
|
||||
Core protocol-level tags required for event identification and ecosystem membership.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
|
||||
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
|
||||
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
|
||||
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
|
||||
|
||||
### 2. Core Identity Tags
|
||||
|
||||
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
|
||||
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
|
||||
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
|
||||
|
||||
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
|
||||
|
||||
### 3. Visual Trait Tags
|
||||
|
||||
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
|
||||
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
|
||||
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
|
||||
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
|
||||
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
|
||||
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
|
||||
|
||||
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
|
||||
|
||||
### 4. Personality / Trait Tags
|
||||
|
||||
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
|
||||
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
|
||||
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
|
||||
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
|
||||
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
|
||||
|
||||
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
|
||||
|
||||
### 5. Stat Tags
|
||||
|
||||
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
|
||||
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
|
||||
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
|
||||
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
|
||||
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
|
||||
|
||||
**Stage Transition Behavior**:
|
||||
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
|
||||
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
|
||||
|
||||
### 6. State / Lifecycle Tags
|
||||
|
||||
Tags that track the Blobbi's current lifecycle state.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
|
||||
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
|
||||
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
|
||||
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
|
||||
|
||||
**State Constraints**:
|
||||
- `incubating` is only valid for `stage: egg`
|
||||
- `evolving` is only valid for `stage: baby`
|
||||
- After hatch/evolve completes, `state` MUST be set to `active`
|
||||
|
||||
### 7. Task System Tags
|
||||
|
||||
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
|
||||
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
|
||||
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
|
||||
|
||||
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
|
||||
|
||||
### 8. Progression Tags
|
||||
|
||||
Long-term progress tracking that persists across all stages.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
|
||||
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
|
||||
|
||||
### 9. Social / Flag Tags
|
||||
|
||||
User preferences and computed flags.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `visible_to_others` | No | egg, baby, adult | Yes | user | `true\|false` | true | Public visibility |
|
||||
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
|
||||
|
||||
### 10. Evolution Tags
|
||||
|
||||
Tags specific to adult Blobbis.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
|
||||
|
||||
### 11. Extension Tags
|
||||
|
||||
Optional tags for themes and crossover features.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
|
||||
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
|
||||
|
||||
---
|
||||
|
||||
## Deprecated Tags
|
||||
|
||||
These tags are from legacy versions and MUST be removed when republishing events.
|
||||
|
||||
| Tag | Reason | Replaced By |
|
||||
|-----|--------|-------------|
|
||||
| `shell_integrity` | Eggs use standard `health` stat | `health` |
|
||||
| `egg_temperature` | Warmth handled via UI props | N/A |
|
||||
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
|
||||
| `egg_status` | Replaced by standard state | `state` |
|
||||
| `fees` | Removed | N/A |
|
||||
| `incubation_time` | Uses state_started_at | `state_started_at` |
|
||||
| `start_incubation` | Uses state_started_at | `state_started_at` |
|
||||
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
|
||||
|
||||
---
|
||||
|
||||
## Stage Transition Rules
|
||||
|
||||
### Hatch (egg → baby)
|
||||
|
||||
**Tags to REMOVE**:
|
||||
- `task`
|
||||
- `task_completed`
|
||||
- `state_started_at`
|
||||
|
||||
**Tags to UPDATE**:
|
||||
- `stage` → `baby`
|
||||
- `state` → `active`
|
||||
- `hunger` → `100`
|
||||
- `happiness` → `100`
|
||||
- `hygiene` → `100`
|
||||
- `energy` → `100`
|
||||
- `health` → (inherited from egg after decay)
|
||||
- `last_interaction` → current timestamp
|
||||
- `last_decay_at` → current timestamp
|
||||
|
||||
**Tags to PRESERVE (all persistent tags)**:
|
||||
- All system tags (`d`, `b`, `t`, `client`)
|
||||
- All identity tags (`name`, `seed`, `generation`)
|
||||
- All visual tags (colors, pattern, size)
|
||||
- All personality tags (if present)
|
||||
- All progression tags (`experience`, `care_streak`)
|
||||
- All social tags (`visible_to_others`, `breeding_ready`)
|
||||
- All extension tags (`theme`, `crossover_app`)
|
||||
|
||||
### Evolve (baby → adult)
|
||||
|
||||
**Tags to REMOVE**:
|
||||
- `task`
|
||||
- `task_completed`
|
||||
- `state_started_at`
|
||||
|
||||
**Tags to UPDATE**:
|
||||
- `stage` → `adult`
|
||||
- `state` → `active`
|
||||
- All stats → (inherited from baby after decay)
|
||||
- `last_interaction` → current timestamp
|
||||
- `last_decay_at` → current timestamp
|
||||
|
||||
**Tags to PRESERVE (all persistent tags)**:
|
||||
- Same as hatch, plus all stats are inherited (not reset)
|
||||
|
||||
**Tags to ADD (optional)**:
|
||||
- `adult_type` → computed based on care history
|
||||
|
||||
---
|
||||
|
||||
## Migration Rules
|
||||
|
||||
When migrating legacy Blobbis to canonical format:
|
||||
|
||||
1. **Always preserve existing values** - Do not regenerate tags that already exist
|
||||
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
|
||||
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
|
||||
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
|
||||
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
A valid Blobbi event MUST have:
|
||||
- `d` tag in canonical format
|
||||
- `b` tag = `blobbi:ecosystem:v1`
|
||||
- `t` tag = `blobbi`
|
||||
- `name` tag (non-empty)
|
||||
- `seed` tag (64 hex chars)
|
||||
- `stage` tag (valid value)
|
||||
- `state` tag (valid value)
|
||||
- `last_interaction` tag (valid timestamp)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When implementing any flow that modifies Blobbi tags:
|
||||
|
||||
- [ ] Start from `canonical.allTags` as the base
|
||||
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
|
||||
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
|
||||
- [ ] Filter out deprecated tags
|
||||
- [ ] Update only the tags that need to change
|
||||
- [ ] Validate required tags are present
|
||||
@@ -39,6 +39,13 @@ export default tseslint.config(
|
||||
},
|
||||
],
|
||||
"custom/no-placeholder-comments": "error",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "CallExpression[callee.object.type='MetaProperty'][callee.property.name='glob']",
|
||||
"message": "import.meta.glob is Vite-only and breaks other bundlers. Inline the assets or use standard imports instead.",
|
||||
},
|
||||
],
|
||||
"no-warning-comments": [
|
||||
"error",
|
||||
{ terms: ["fixme"] },
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
|
||||
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,7 +325,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 98 KiB |
@@ -13,7 +13,9 @@ let package = Package(
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||
],
|
||||
targets: [
|
||||
@@ -23,7 +25,9 @@ let package = Package(
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mkstack",
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"version": "2.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -11,13 +11,14 @@
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
},
|
||||
"engines": {
|
||||
"npm": "10.9.4",
|
||||
"node": "22.x"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -26,6 +27,7 @@
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@fontsource-variable/comfortaa": "^5.2.8",
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/fredoka": "^5.2.10",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
@@ -35,19 +37,23 @@
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
"@fontsource/comic-neue": "^5.2.7",
|
||||
"@fontsource/comic-relief": "^5.2.2",
|
||||
"@fontsource/courier-prime": "^5.2.8",
|
||||
"@fontsource/creepster": "^5.2.7",
|
||||
"@fontsource/luckiest-guy": "^5.2.8",
|
||||
"@fontsource/pacifico": "^5.2.7",
|
||||
"@fontsource/permanent-marker": "^5.2.7",
|
||||
"@fontsource/pirata-one": "^5.2.8",
|
||||
"@fontsource/press-start-2p": "^5.2.7",
|
||||
"@fontsource/silkscreen": "^5.2.8",
|
||||
"@fontsource/special-elite": "^5.2.8",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@nostrify/nostrify": "^0.51.0",
|
||||
"@nostrify/react": "^0.3.1",
|
||||
"@nostrify/react": "^0.4.0",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -56,18 +62,18 @@
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -76,7 +82,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
@@ -87,19 +93,21 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-blurhash": "^0.3.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
@@ -112,7 +120,7 @@
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uri-templates": "^0.2.0",
|
||||
"vaul": "^0.9.3",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -123,12 +131,14 @@
|
||||
"@html-eslint/eslint-plugin": "^0.41.0",
|
||||
"@html-eslint/parser": "^0.41.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"@webxdc/types": "^2.1.2",
|
||||
@@ -139,10 +149,15 @@
|
||||
"globals": "^15.9.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^8.0.0",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
|
||||
- Mobile header no longer shows double-layered backgrounds on notched devices
|
||||
- Pinned tabs stay properly positioned when scrolling on mobile
|
||||
- Signer approval toasts no longer fire in rapid succession on unstable connections
|
||||
- Toasts are easier to swipe away on mobile
|
||||
- Content warnings now blur thumbnails in the media grid
|
||||
|
||||
## [2.2.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen vines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber and NIP-46 users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
### Changed
|
||||
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
|
||||
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
|
||||
- Upgraded from React 18 to React 19
|
||||
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
|
||||
|
||||
### Fixed
|
||||
- Zapping Primal users no longer produces an error
|
||||
- Hashtag feeds now match case-insensitively for parity with search results
|
||||
- Mobile top bar arc no longer lingers on pages without tabs
|
||||
- Give Badge dialog and profile menu action handlers
|
||||
|
||||
## [2.1.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Emoji picker and shortcode autocomplete in zap comment box
|
||||
- Zap button on badge detail view
|
||||
- Theme descriptions now display on "updated their theme" posts and detail pages
|
||||
- Badge thumbnail previews in award notifications
|
||||
- Letter notifications with envelope card preview
|
||||
- Kind-specific labels in notification text instead of generic "post"
|
||||
|
||||
### Fixed
|
||||
- Compose modal no longer closes when dismissing emoji picker on mobile
|
||||
- Compose preview overflow is now scrollable in modal
|
||||
- Toast notifications swipe up to dismiss on mobile instead of sideways
|
||||
- File downloads and URL opening work correctly on iOS
|
||||
- Badges page no longer shows infinite skeleton when logged out
|
||||
|
||||
## [2.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
|
||||
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
|
||||
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
|
||||
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
|
||||
- Letters page added to the sidebar with a custom mailbox icon
|
||||
|
||||
## [2.0.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Tap the version number in settings to see what's new
|
||||
|
||||
## [2.0.0] - 2026-03-26
|
||||
|
||||
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
|
||||
@@ -6,7 +6,7 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Generating Android app icons...${NC}\n"
|
||||
echo -e "${GREEN}Generating app icons...${NC}\n"
|
||||
|
||||
# Check for inkscape (preferred) or rsvg-convert as fallback
|
||||
if command -v inkscape &> /dev/null; then
|
||||
@@ -138,12 +138,33 @@ cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
|
||||
</resources>
|
||||
EOF
|
||||
|
||||
# ── iOS App Icon (1024x1024, white logo on purple background) ──
|
||||
|
||||
echo "Generating iOS app icon..."
|
||||
|
||||
IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
|
||||
|
||||
if [ -d "$IOS_ICON_DIR" ]; then
|
||||
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
|
||||
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
|
||||
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
|
||||
\( "$LOGO_WHITE" -resize "614x614" \) \
|
||||
-gravity center -compose over -composite \
|
||||
"$IOS_ICON"
|
||||
echo -e " ${GREEN}✓${NC} $IOS_ICON"
|
||||
else
|
||||
echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}"
|
||||
fi
|
||||
|
||||
# Cleanup temp files
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
echo -e "\n${GREEN}Android icons generated successfully!${NC}"
|
||||
echo -e "\n${GREEN}App icons generated successfully!${NC}"
|
||||
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
|
||||
echo -e "Generated:"
|
||||
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
|
||||
echo -e " - ic_launcher.png (legacy square, all densities)"
|
||||
echo -e " - ic_launcher_round.png (legacy round, all densities)"
|
||||
echo -e " Android:"
|
||||
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
|
||||
echo -e " - ic_launcher.png (legacy square, all densities)"
|
||||
echo -e " - ic_launcher_round.png (legacy round, all densities)"
|
||||
echo -e " iOS:"
|
||||
echo -e " - AppIcon-512@2x.png (1024x1024)"
|
||||
|
||||
@@ -18,9 +18,11 @@ import { PlausibleProvider } from "@/components/PlausibleProvider";
|
||||
import { SentryProvider } from "@/components/SentryProvider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
const dmConfig: DMConfig = {
|
||||
@@ -156,6 +158,8 @@ const defaultConfig: AppConfig = {
|
||||
};
|
||||
|
||||
export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize StatusBar for mobile apps
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
@@ -180,12 +184,14 @@ export function App() {
|
||||
<NativeNotifications />
|
||||
<NWCProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
</TooltipProvider>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
</TooltipProvider>
|
||||
</EmotionDevProvider>
|
||||
</DMProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
|
||||
@@ -1,59 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
|
||||
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
|
||||
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { ReplyComposeModal } from "@/components/ReplyComposeModal";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { getExtraKindDef } from "./lib/extraKinds";
|
||||
import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage";
|
||||
import { AIChatPage } from "./pages/AIChatPage";
|
||||
import { BadgesPage } from "./pages/BadgesPage";
|
||||
import { BookmarksPage } from "./pages/BookmarksPage";
|
||||
import { BooksPage } from "./pages/BooksPage";
|
||||
import { ContentPage } from "./pages/ContentPage";
|
||||
import { ContentSettingsPage } from "./pages/ContentSettingsPage";
|
||||
import { DomainFeedPage } from "./pages/DomainFeedPage";
|
||||
import { EventsFeedPage } from "./pages/EventsFeedPage";
|
||||
import { ExternalContentPage } from "./pages/ExternalContentPage";
|
||||
import { GeotagPage } from "./pages/GeotagPage";
|
||||
import { HashtagPage } from "./pages/HashtagPage";
|
||||
import { HelpPage } from "./pages/HelpPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
|
||||
// Critical-path pages: eagerly loaded (landing + fallback)
|
||||
import Index from "./pages/Index";
|
||||
import { KindFeedPage } from "./pages/KindFeedPage";
|
||||
import { MagicSettingsPage } from "./pages/MagicSettingsPage";
|
||||
import { MusicFeedPage } from "./pages/MusicFeedPage";
|
||||
import { NetworkSettingsPage } from "./pages/NetworkSettingsPage";
|
||||
import { NIP19Page } from "./pages/NIP19Page";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import { NotificationSettings } from "./pages/NotificationSettings";
|
||||
import { NotificationsPage } from "./pages/NotificationsPage";
|
||||
import { PhotosFeedPage } from "./pages/PhotosFeedPage";
|
||||
import { PodcastsFeedPage } from "./pages/PodcastsFeedPage";
|
||||
import { CSAEPolicyPage } from "./pages/CSAEPolicyPage";
|
||||
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
|
||||
import { ProfileSettings } from "./pages/ProfileSettings";
|
||||
import { RelayPage } from "./pages/RelayPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { ThemesPage } from "./pages/ThemesPage";
|
||||
import { TreasuresPage } from "./pages/TreasuresPage";
|
||||
import { TrendsPage } from "./pages/TrendsPage";
|
||||
import { UserListsPage } from "./pages/UserListsPage";
|
||||
import { VideosFeedPage } from "./pages/VideosFeedPage";
|
||||
import { VinesFeedPage } from "./pages/VinesFeedPage";
|
||||
import { WalletSettingsPage } from "./pages/WalletSettingsPage";
|
||||
import { WebxdcFeedPage } from "./pages/WebxdcFeedPage";
|
||||
import { WorldPage } from "./pages/WorldPage";
|
||||
import { ArchivePage } from "./pages/ArchivePage";
|
||||
import { BlueskyPage } from "./pages/BlueskyPage";
|
||||
import { WikipediaPage } from "./pages/WikipediaPage";
|
||||
|
||||
// Lazy-loaded companion layer (~450K code-split)
|
||||
const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => ({ default: m.BlobbiCompanionLayer })));
|
||||
|
||||
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
|
||||
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
|
||||
// All other pages: code-split via React.lazy
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
|
||||
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
|
||||
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
|
||||
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
|
||||
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
|
||||
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
const MusicFeedPage = lazy(() => import("./pages/MusicFeedPage").then(m => ({ default: m.MusicFeedPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
|
||||
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
|
||||
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
|
||||
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
|
||||
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
|
||||
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
|
||||
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
|
||||
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
|
||||
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
|
||||
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
|
||||
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
|
||||
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
|
||||
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
|
||||
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
const colorsDef = getExtraKindDef("colors")!;
|
||||
@@ -74,7 +90,11 @@ function PollsFeedPage() {
|
||||
icon={sidebarItemIcon("polls", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +115,11 @@ export function AppRouter() {
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
<ScrollToTop />
|
||||
<BlobbiActionsProvider>
|
||||
<Suspense fallback={null}>
|
||||
<BlobbiCompanionLayer />
|
||||
</Suspense>
|
||||
</BlobbiActionsProvider>
|
||||
<Routes>
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
@@ -204,15 +229,19 @@ export function AppRouter() {
|
||||
<Route path="/themes" element={<ThemesPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/blobbi" element={<BlobbiPage />} />
|
||||
<Route path="/world" element={<WorldPage />} />
|
||||
<Route path="/badges" element={<BadgesPage />} />
|
||||
<Route path="/books" element={<BooksPage />} />
|
||||
<Route path="/archive" element={<ArchivePage />} />
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/r/*" element={<RelayPage />} />
|
||||
<Route
|
||||
path="/settings/lists"
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2, ShoppingBag, Minus, Plus, 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 '@/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
filterInventoryByAction,
|
||||
previewStatChanges,
|
||||
previewMedicineForEgg,
|
||||
previewCleanForEgg,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
ACTION_METADATA,
|
||||
type InventoryAction,
|
||||
type ResolvedInventoryItem,
|
||||
type EggStatPreview,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
|
||||
interface BlobbiActionInventoryModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
action: InventoryAction;
|
||||
companion: BlobbiCompanion;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called when user confirms using item(s). Now accepts quantity. */
|
||||
onUseItem: (itemId: string, quantity: number) => void;
|
||||
onOpenShop: () => void;
|
||||
isUsingItem: boolean;
|
||||
usingItemId: string | null;
|
||||
}
|
||||
|
||||
export function BlobbiActionInventoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
profile,
|
||||
onUseItem,
|
||||
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
|
||||
const availableItems = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
|
||||
}, [profile, action, companion.stage]);
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
const canUse = canUseAction(companion, action);
|
||||
const stageMessage = getStageRestrictionMessage(companion, action);
|
||||
|
||||
const isEmpty = availableItems.length === 0;
|
||||
|
||||
const handleSelectItem = (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));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
|
||||
{actionMeta.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate">
|
||||
{actionMeta.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
{/* Stage Restriction Message */}
|
||||
{!canUse && stageMessage && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">🥚</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
{stageMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{canUse && isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<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!
|
||||
</p>
|
||||
<Button onClick={handleOpenShop} className="gap-2">
|
||||
<ShoppingBag className="size-4" />
|
||||
Open Shop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item List */}
|
||||
{canUse && !isEmpty && (
|
||||
<div className="grid gap-3">
|
||||
{availableItems.map((item) => (
|
||||
<BlobbiInventoryUseRow
|
||||
key={item.itemId}
|
||||
item={item}
|
||||
companion={companion}
|
||||
action={action}
|
||||
onUse={() => handleSelectItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
disabled={isUsingItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inventory Use Row ────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiInventoryUseRowProps {
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
action: InventoryAction;
|
||||
onUse: () => void;
|
||||
isUsing: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function BlobbiInventoryUseRow({
|
||||
item,
|
||||
companion,
|
||||
action,
|
||||
onUse,
|
||||
isUsing,
|
||||
disabled,
|
||||
}: BlobbiInventoryUseRowProps) {
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// 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(
|
||||
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
|
||||
item.effect
|
||||
),
|
||||
};
|
||||
}
|
||||
// Normal stats preview
|
||||
return {
|
||||
normalStatChanges: previewStatChanges(companion.stats, item.effect),
|
||||
eggStatChanges: [] as EggStatPreview[],
|
||||
};
|
||||
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
|
||||
{/* Top row on mobile: Icon + Info + 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="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.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
<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
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onUse}
|
||||
disabled={disabled}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isUsing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// src/blobbi/actions/components/BlobbiActionsModal.tsx
|
||||
|
||||
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
|
||||
|
||||
interface BlobbiActionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companion: BlobbiCompanion;
|
||||
onRest: () => void;
|
||||
onInventoryAction: (action: InventoryAction) => void;
|
||||
onDirectAction: (action: DirectAction) => void;
|
||||
actionInProgress: string | null;
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
export function BlobbiActionsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
onRest,
|
||||
onInventoryAction,
|
||||
onDirectAction,
|
||||
actionInProgress,
|
||||
isPublishing,
|
||||
}: BlobbiActionsModalProps) {
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
const isDisabled = isPublishing || actionInProgress !== null;
|
||||
const isEgg = companion.stage === 'egg';
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle>Blobbi Actions</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">{companion.name}</p>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="grid gap-3">
|
||||
{/* Feed Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('feed'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Utensils className="size-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Feed</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Give your Blobbi something to eat
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Play Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('play'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Gamepad2 className="size-5 text-yellow-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Play</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Play with toys to make your Blobbi happy
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Clean Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('clean'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SparklesIcon className="size-5 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Clean</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Keep your egg clean and fresh'
|
||||
: 'Keep your Blobbi clean and fresh'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Medicine Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('medicine'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Pill className="size-5 text-green-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Medicine</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Keep your egg healthy'
|
||||
: 'Heal your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Play Music Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onDirectAction('play_music'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Music className="size-5 text-pink-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Play Music</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Play soothing music for your egg'
|
||||
: 'Play music for your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Sing Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onDirectAction('sing'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Mic className="size-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Sing</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Sing a lullaby to your egg'
|
||||
: 'Sing to your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Sleep/Wake Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(onRest)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{actionInProgress === 'rest' ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : isSleeping ? (
|
||||
<Sun className="size-5 text-amber-500" />
|
||||
) : (
|
||||
<Moon className="size-5 text-violet-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{isSleeping ? 'Wake Up' : 'Sleep'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSleeping ? 'Wake your Blobbi up' : 'Put your Blobbi to sleep'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
|
||||
|
||||
/**
|
||||
* Missions modal for Blobbi.
|
||||
*
|
||||
* Shows:
|
||||
* - Daily missions (always visible, separate reward system)
|
||||
* - Incubation tasks when the current Blobbi is incubating (egg stage)
|
||||
* - Evolve tasks when evolving (baby stage)
|
||||
*/
|
||||
|
||||
import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
|
||||
import { formatCompactNumber, cn } from '@/lib/utils';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { HatchTasksResult } from '../hooks/useHatchTasks';
|
||||
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
|
||||
import { TasksPanel } from './TasksPanel';
|
||||
import { DailyMissionsPanel } from './DailyMissionsPanel';
|
||||
import { useDailyMissions } from '../hooks/useDailyMissions';
|
||||
import { useClaimMissionReward } from '../hooks/useClaimMissionReward';
|
||||
import { useRerollMission } from '../hooks/useRerollMission';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiMissionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Current companion being viewed */
|
||||
companion: BlobbiCompanion;
|
||||
/** Current Blobbonaut profile (required for coin updates) */
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Callback to update profile in query cache after claiming */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Hatch tasks result from useHatchTasks */
|
||||
hatchTasks: HatchTasksResult;
|
||||
/** Evolve tasks result from useEvolveTasks */
|
||||
evolveTasks: EvolveTasksResult;
|
||||
/** Called when user clicks "Create Post" action in tasks */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all hatch tasks are complete and user clicks "Hatch" */
|
||||
onHatch: () => void;
|
||||
/** Whether hatching is in progress */
|
||||
isHatching: boolean;
|
||||
/** Called when all evolve tasks are complete and user clicks "Evolve" */
|
||||
onEvolve: () => void;
|
||||
/** Whether evolving is in progress */
|
||||
isEvolving: boolean;
|
||||
/** Called when user confirms stopping incubation */
|
||||
onStopIncubation: () => Promise<void>;
|
||||
/** Whether stop incubation is in progress */
|
||||
isStoppingIncubation: boolean;
|
||||
/** Called when user confirms stopping evolution */
|
||||
onStopEvolution: () => Promise<void>;
|
||||
/** Whether stop evolution is in progress */
|
||||
isStoppingEvolution: boolean;
|
||||
/** Available Blobbi stages across all user's companions (for mission filtering) */
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
}
|
||||
|
||||
// ─── Daily Missions Section ───────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsSectionProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Available Blobbi stages the user has */
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const {
|
||||
missions,
|
||||
todayClaimedReward,
|
||||
totalPotentialReward,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
} = useDailyMissions({ availableStages });
|
||||
|
||||
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
|
||||
profile,
|
||||
updateProfileEvent
|
||||
);
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
|
||||
const handleClaimReward = (missionId: string) => {
|
||||
claimReward({ missionId });
|
||||
};
|
||||
|
||||
const handleRerollMission = (missionId: string) => {
|
||||
rerollMission({ missionId, availableStages });
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
{/* Section header - Clickable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="size-4 text-primary shrink-0" />
|
||||
<h3 className="font-semibold text-sm">Daily Missions</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className={cn(
|
||||
"size-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Mission list */}
|
||||
<CollapsibleContent className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onClaimReward={handleClaimReward}
|
||||
onRerollMission={handleRerollMission}
|
||||
todayCoins={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
bonusReward={bonusReward}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
|
||||
|
||||
interface StopConfirmationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companionName: string;
|
||||
processType: 'incubation' | 'evolution';
|
||||
onConfirm: () => Promise<void>;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
function StopConfirmationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companionName,
|
||||
processType,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StopConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-amber-500" />
|
||||
Stop {label}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>
|
||||
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
|
||||
<strong>{companionName}</strong>?
|
||||
</p>
|
||||
<p>
|
||||
This will interrupt the {processType} process and clear all task progress.
|
||||
You can restart {processType} later, but you'll need to complete the tasks again.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
`Stop ${label}`
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
|
||||
|
||||
interface ProcessContentProps {
|
||||
companion: BlobbiCompanion;
|
||||
tasks: HatchTasksResult | EvolveTasksResult;
|
||||
processType: 'incubation' | 'evolution';
|
||||
onOpenPostModal: () => void;
|
||||
onComplete: () => void;
|
||||
isCompleting: boolean;
|
||||
onStop: () => Promise<void>;
|
||||
isStopping: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function ProcessContent({
|
||||
companion,
|
||||
tasks,
|
||||
processType,
|
||||
onOpenPostModal,
|
||||
onComplete,
|
||||
isCompleting,
|
||||
onStop,
|
||||
isStopping,
|
||||
defaultOpen = true,
|
||||
}: ProcessContentProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
|
||||
|
||||
const isIncubation = processType === 'incubation';
|
||||
const emoji = isIncubation ? '🥚' : '🐣';
|
||||
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
|
||||
const description = isIncubation
|
||||
? 'Complete these tasks to hatch your Blobbi'
|
||||
: 'Complete these tasks to evolve your Blobbi';
|
||||
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
|
||||
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
|
||||
const completeEmoji = isIncubation ? '🐣' : '✨';
|
||||
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
|
||||
|
||||
const completedCount = tasks.tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.tasks.length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
{/* Section header - Clickable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{emoji}</span>
|
||||
<h3 className="font-semibold text-sm">{title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full",
|
||||
tasks.allCompleted
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{completedCount}/{totalTasks}
|
||||
</span>
|
||||
<ChevronDown className={cn(
|
||||
"size-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Tasks content */}
|
||||
<CollapsibleContent className="pt-3">
|
||||
{/* Tasks Panel */}
|
||||
<TasksPanel
|
||||
tasks={tasks.tasks}
|
||||
allCompleted={tasks.allCompleted}
|
||||
isLoading={tasks.isLoading}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onComplete}
|
||||
isCompleting={isCompleting}
|
||||
emoji={emoji}
|
||||
title={title}
|
||||
description={description}
|
||||
completeLabel={completeLabel}
|
||||
completingLabel={completingLabel}
|
||||
completeEmoji={completeEmoji}
|
||||
/>
|
||||
|
||||
{/* Stop Process Button */}
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowStopConfirmation(true)}
|
||||
disabled={isStopping || isCompleting}
|
||||
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-4 mr-2" />
|
||||
{stopLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
{/* Stop Confirmation Dialog */}
|
||||
<StopConfirmationDialog
|
||||
open={showStopConfirmation}
|
||||
onOpenChange={setShowStopConfirmation}
|
||||
companionName={companion.name}
|
||||
processType={processType}
|
||||
onConfirm={onStop}
|
||||
isPending={isStopping}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiMissionsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
hatchTasks,
|
||||
evolveTasks,
|
||||
onOpenPostModal,
|
||||
onHatch,
|
||||
isHatching,
|
||||
onEvolve,
|
||||
isEvolving,
|
||||
onStopIncubation,
|
||||
isStoppingIncubation,
|
||||
onStopEvolution,
|
||||
isStoppingEvolution,
|
||||
availableStages,
|
||||
}: BlobbiMissionsModalProps) {
|
||||
const isIncubating = companion.state === 'incubating';
|
||||
const isEvolvingState = companion.state === 'evolving';
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isBaby = companion.stage === 'baby';
|
||||
|
||||
// Check if there's an active hatch/evolve process
|
||||
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
|
||||
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Target className="size-5 shrink-0" />
|
||||
Missions
|
||||
</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
Complete missions to earn rewards for {companion.name}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-6 py-3 sm:py-4 space-y-4">
|
||||
{/* Daily Missions Section - Always visible, expanded by default */}
|
||||
<DailyMissionsSection
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
|
||||
{/* Hatch/Evolve Process Section - Only when active, expanded by default */}
|
||||
{hasActiveProcess && (
|
||||
<>
|
||||
{isIncubating && isEgg ? (
|
||||
<ProcessContent
|
||||
companion={companion}
|
||||
tasks={hatchTasks}
|
||||
processType="incubation"
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onHatch}
|
||||
isCompleting={isHatching}
|
||||
onStop={onStopIncubation}
|
||||
isStopping={isStoppingIncubation}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
) : isEvolvingState && isBaby ? (
|
||||
<ProcessContent
|
||||
companion={companion}
|
||||
tasks={evolveTasks}
|
||||
processType="evolution"
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onEvolve}
|
||||
isCompleting={isEvolving}
|
||||
onStop={onStopEvolution}
|
||||
isStopping={isStoppingEvolution}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// src/blobbi/actions/components/BlobbiPostModal.tsx
|
||||
|
||||
/**
|
||||
* Modal for creating a Blobbi post (hatch or evolve).
|
||||
*
|
||||
* Requirements:
|
||||
* - Prefilled with stage-aware text:
|
||||
* - Hatch: "Hello Nostr! Posting to hatch #<blobbiName> #blobbi #ditto #nostr"
|
||||
* - Evolve: "Hello Nostr! Posting to evolve #<blobbiName> #blobbi #ditto #nostr"
|
||||
* - User can ADD text but CANNOT delete the prefix or required hashtags
|
||||
* - Blobbi name is sanitized into a valid hashtag format
|
||||
* - Enforced programmatically
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { X, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
} from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The process type for the post */
|
||||
export type BlobbiPostProcess = 'hatch' | 'evolve';
|
||||
|
||||
interface BlobbiPostModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi's name (will be converted to hashtag) */
|
||||
blobbiName: string;
|
||||
/** The process type - 'hatch' for incubation, 'evolve' for evolution */
|
||||
process?: BlobbiPostProcess;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitize a name into a valid hashtag format.
|
||||
* - Removes special characters
|
||||
* - Replaces spaces with nothing (camelCase-like)
|
||||
* - Ensures lowercase
|
||||
* - Handles edge cases
|
||||
*/
|
||||
function sanitizeToHashtag(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
// Remove emojis and special characters, keep letters, numbers, underscores
|
||||
.replace(/[^\p{L}\p{N}_]/gu, '')
|
||||
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
|
||||
.replace(/^(\d)/, 'blobbi$1')
|
||||
// Limit length
|
||||
.slice(0, 30)
|
||||
// Fallback if empty
|
||||
|| 'myblobbi';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the required prefix text based on process type.
|
||||
*/
|
||||
function buildPrefix(process: BlobbiPostProcess): string {
|
||||
return process === 'evolve'
|
||||
? 'Hello Nostr! Posting to evolve'
|
||||
: 'Hello Nostr! Posting to hatch';
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPostModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
blobbiName,
|
||||
process = 'hatch',
|
||||
onSuccess,
|
||||
}: BlobbiPostModalProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
|
||||
// Compute the required elements based on props
|
||||
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
|
||||
const prefix = useMemo(() => buildPrefix(process), [process]);
|
||||
|
||||
// All required hashtags including the Blobbi name (first)
|
||||
const allRequiredHashtags = useMemo(() =>
|
||||
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
|
||||
[blobbiHashtag]
|
||||
);
|
||||
|
||||
// Build default content
|
||||
const defaultContent = useMemo(() =>
|
||||
`${prefix} #${allRequiredHashtags.join(' #')}`,
|
||||
[prefix, allRequiredHashtags]
|
||||
);
|
||||
|
||||
const [content, setContent] = useState(defaultContent);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// Reset content when modal opens or props change
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setContent(defaultContent);
|
||||
setValidationError(null);
|
||||
}
|
||||
}, [open, defaultContent]);
|
||||
|
||||
/**
|
||||
* Validate that the content still contains the required prefix and hashtags.
|
||||
*/
|
||||
const validateContent = useCallback((text: string): string | null => {
|
||||
// Check prefix
|
||||
if (!text.startsWith(prefix)) {
|
||||
return 'The post must start with the required text';
|
||||
}
|
||||
|
||||
// Check all required hashtags are present (including Blobbi name)
|
||||
const lowerText = text.toLowerCase();
|
||||
for (const tag of allRequiredHashtags) {
|
||||
if (!lowerText.includes(`#${tag.toLowerCase()}`)) {
|
||||
return `Missing required hashtag: #${tag}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [prefix, allRequiredHashtags]);
|
||||
|
||||
/**
|
||||
* Handle content change with validation.
|
||||
* Prevents deletion of required content.
|
||||
*/
|
||||
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value;
|
||||
|
||||
// Allow content changes only if it preserves the required elements
|
||||
const error = validateContent(newContent);
|
||||
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
// Still update content but show error
|
||||
// This allows the user to see what they're trying to do
|
||||
// but the post button will be disabled
|
||||
} else {
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
setContent(newContent);
|
||||
}, [validateContent]);
|
||||
|
||||
/**
|
||||
* Handle post creation.
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user?.pubkey) {
|
||||
toast({
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to create a post',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Final validation
|
||||
const error = validateContent(content);
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tags for the post
|
||||
const tags: string[][] = [];
|
||||
|
||||
// Add all required hashtags as 't' tags
|
||||
for (const hashtag of allRequiredHashtags) {
|
||||
tags.push(['t', hashtag.toLowerCase()]);
|
||||
}
|
||||
|
||||
// Extract any additional hashtags the user added
|
||||
const additionalHashtags = content.match(/#(\w+)/g) || [];
|
||||
const requiredLower = allRequiredHashtags.map(t => t.toLowerCase());
|
||||
for (const tag of additionalHashtags) {
|
||||
const tagValue = tag.slice(1).toLowerCase();
|
||||
if (!requiredLower.includes(tagValue)) {
|
||||
tags.push(['t', tagValue]);
|
||||
}
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
tags,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Post created!',
|
||||
description: process === 'evolve'
|
||||
? 'Your Blobbi evolution post has been published.'
|
||||
: 'Your Blobbi hatch post has been published.',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to create post',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
|
||||
|
||||
const canPost = !validationError && content.trim().length > 0;
|
||||
|
||||
const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post';
|
||||
const alertText = process === 'evolve'
|
||||
? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post."
|
||||
: "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg p-0 gap-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-14 border-b">
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{dialogTitle}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Info alert */}
|
||||
<Alert className="border-primary/20 bg-primary/5">
|
||||
<AlertDescription className="text-sm">
|
||||
{alertText}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="Write your post..."
|
||||
className="min-h-[150px] resize-none"
|
||||
disabled={isPending}
|
||||
/>
|
||||
|
||||
{/* Character count and validation */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
{validationError && (
|
||||
<span className="text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{validationError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview of required content */}
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
|
||||
<p className="text-xs text-muted-foreground mb-1">Required content:</p>
|
||||
<p className="text-sm font-medium">
|
||||
<span className="text-primary">{prefix}</span>
|
||||
{' '}
|
||||
{allRequiredHashtags.map(tag => (
|
||||
<span key={tag} className="text-blue-500">#{tag} </span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/30">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={!canPost || isPending}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
'Post'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* DailyMissionsPanel - UI component for displaying daily missions
|
||||
*
|
||||
* Shows:
|
||||
* - Daily mission list with progress bars
|
||||
* - Completion state
|
||||
* - Claim buttons for completed missions
|
||||
* - Coin rewards
|
||||
* - Bonus mission after completing all regular missions
|
||||
* - Empty state when no missions available (egg-only users)
|
||||
* - Reroll button to replace missions (max 3/day)
|
||||
*/
|
||||
|
||||
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
import type { DailyMission } from '../lib/daily-missions';
|
||||
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsPanelProps {
|
||||
/** The daily missions to display */
|
||||
missions: DailyMission[];
|
||||
/** Callback when claiming a mission reward */
|
||||
onClaimReward: (missionId: string) => void;
|
||||
/** Callback when rerolling a mission */
|
||||
onRerollMission?: (missionId: string) => void;
|
||||
/** Total coins earned today */
|
||||
todayCoins: number;
|
||||
/** Whether claiming is disabled (e.g., during another operation) */
|
||||
disabled?: boolean;
|
||||
/** Whether the bonus mission is available */
|
||||
bonusAvailable?: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
bonusClaimed?: boolean;
|
||||
/** Bonus mission reward amount */
|
||||
bonusReward?: number;
|
||||
/** Whether user has no eligible missions (e.g., only eggs) */
|
||||
noMissionsAvailable?: boolean;
|
||||
/** Number of rerolls remaining today */
|
||||
rerollsRemaining?: number;
|
||||
/** Whether a reroll is currently in progress */
|
||||
isRerolling?: boolean;
|
||||
}
|
||||
|
||||
// ─── Mission Item ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface MissionItemProps {
|
||||
mission: DailyMission;
|
||||
onClaim: () => void;
|
||||
onReroll?: () => void;
|
||||
disabled?: boolean;
|
||||
canReroll?: boolean;
|
||||
isRerolling?: boolean;
|
||||
}
|
||||
|
||||
function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) {
|
||||
const progressPercent = (mission.currentCount / mission.requiredCount) * 100;
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
|
||||
// Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining
|
||||
const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-3 sm:p-4 rounded-lg border transition-colors overflow-hidden',
|
||||
mission.claimed
|
||||
? 'bg-primary/5 border-primary/20'
|
||||
: mission.completed
|
||||
? 'bg-green-500/5 border-green-500/30'
|
||||
: 'bg-card border-border'
|
||||
)}
|
||||
>
|
||||
{/* Top right area: Claimed badge OR Reroll button */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{mission.claimed ? (
|
||||
<div className="flex items-center gap-1 text-xs text-primary font-medium">
|
||||
<Check className="size-3" />
|
||||
Claimed
|
||||
</div>
|
||||
) : showRerollButton ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={onReroll}
|
||||
disabled={disabled || isRerolling}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", isRerolling && "animate-spin")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Replace this mission</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Mission content */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Title and description */}
|
||||
<div className="pr-14 sm:pr-16">
|
||||
<h4 className="font-medium text-sm break-words">{mission.title}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 break-words">
|
||||
{mission.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
{mission.currentCount} / {mission.requiredCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
<Coins className="size-3 shrink-0" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
className={cn(
|
||||
'h-2',
|
||||
mission.completed && '[&>div]:bg-green-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Gift className="size-4 mr-2 shrink-0" />
|
||||
<span className="truncate">Claim {formatCompactNumber(mission.reward)} Coins</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bonus Mission Item ───────────────────────────────────────────────────────
|
||||
|
||||
interface BonusMissionItemProps {
|
||||
isAvailable: boolean;
|
||||
isClaimed: boolean;
|
||||
reward: number;
|
||||
onClaim: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-3 sm:p-4 rounded-lg border-2 transition-colors overflow-hidden',
|
||||
isClaimed
|
||||
? 'bg-amber-500/10 border-amber-500/30'
|
||||
: isAvailable
|
||||
? 'bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-500/40 animate-pulse'
|
||||
: 'bg-muted/30 border-dashed border-muted-foreground/20'
|
||||
)}
|
||||
>
|
||||
{/* Claimed badge */}
|
||||
{isClaimed && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
|
||||
<Check className="size-3" />
|
||||
Claimed
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mission content */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Title and description */}
|
||||
<div className={cn("pr-14 sm:pr-16", !isAvailable && !isClaimed && "opacity-50")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className={cn(
|
||||
"size-4 shrink-0",
|
||||
isClaimed
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: isAvailable
|
||||
? "text-amber-500"
|
||||
: "text-muted-foreground"
|
||||
)} />
|
||||
<h4 className="font-medium text-sm">Daily Champion</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{isAvailable || isClaimed
|
||||
? 'Bonus reward for completing all daily missions!'
|
||||
: 'Complete all missions above to unlock this bonus'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reward display */}
|
||||
<div className="flex items-center justify-between text-xs gap-2">
|
||||
<span className={cn(
|
||||
"text-muted-foreground",
|
||||
!isAvailable && !isClaimed && "opacity-50"
|
||||
)}>
|
||||
Bonus Reward
|
||||
</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 font-medium",
|
||||
isClaimed || isAvailable
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
<Coins className="size-3 shrink-0" />
|
||||
+{formatCompactNumber(reward)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{isAvailable && !isClaimed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
<Trophy className="size-4 mr-2 shrink-0" />
|
||||
<span className="truncate">Claim Bonus {formatCompactNumber(reward)} Coins!</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── No Missions Available State ──────────────────────────────────────────────
|
||||
|
||||
function NoMissionsState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Egg className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">Hatch Your Blobbi First</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Daily missions will be available once you have
|
||||
<br />
|
||||
a hatched Blobbi to interact with!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── All Claimed State ────────────────────────────────────────────────────────
|
||||
|
||||
interface AllClaimedStateProps {
|
||||
todayCoins: number;
|
||||
}
|
||||
|
||||
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="size-6 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">All Done for Today!</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You earned <span className="font-medium text-amber-600 dark:text-amber-400">{formatCompactNumber(todayCoins)} coins</span> today.
|
||||
<br />
|
||||
Come back tomorrow for new missions!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reroll Counter ───────────────────────────────────────────────────────────
|
||||
|
||||
interface RerollCounterProps {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
function RerollCounter({ remaining }: RerollCounterProps) {
|
||||
const text = remaining === 0
|
||||
? 'No rerolls left'
|
||||
: remaining === 1
|
||||
? '1 reroll left'
|
||||
: `${remaining} rerolls left`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<RefreshCw className="size-3" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function DailyMissionsPanel({
|
||||
missions,
|
||||
onClaimReward,
|
||||
onRerollMission,
|
||||
todayCoins,
|
||||
disabled,
|
||||
bonusAvailable = false,
|
||||
bonusClaimed = false,
|
||||
bonusReward = 50,
|
||||
noMissionsAvailable = false,
|
||||
rerollsRemaining = 0,
|
||||
isRerolling = false,
|
||||
}: DailyMissionsPanelProps) {
|
||||
// Show empty state if user has no eligible missions (e.g., only eggs)
|
||||
if (noMissionsAvailable) {
|
||||
return <NoMissionsState />;
|
||||
}
|
||||
|
||||
const allRegularClaimed = missions.every((m) => m.claimed);
|
||||
const allDone = allRegularClaimed && bonusClaimed;
|
||||
|
||||
// Show "all done" state only when everything including bonus is claimed
|
||||
if (allDone) {
|
||||
return <AllClaimedState todayCoins={todayCoins} />;
|
||||
}
|
||||
|
||||
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Reroll counter - only show if reroll functionality is available */}
|
||||
{onRerollMission && (
|
||||
<RerollCounter remaining={rerollsRemaining} />
|
||||
)}
|
||||
|
||||
{/* Regular missions */}
|
||||
{missions.map((mission) => (
|
||||
<MissionItem
|
||||
key={mission.id}
|
||||
mission={mission}
|
||||
onClaim={() => onClaimReward(mission.id)}
|
||||
onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined}
|
||||
disabled={disabled}
|
||||
canReroll={canReroll}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bonus mission - always visible */}
|
||||
<BonusMissionItem
|
||||
isAvailable={bonusAvailable}
|
||||
isClaimed={bonusClaimed}
|
||||
reward={bonusReward}
|
||||
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// src/blobbi/actions/components/HatchTasksPanel.tsx
|
||||
|
||||
/**
|
||||
* UI component for displaying hatch task progress.
|
||||
* Shows a list of tasks with progress indicators and action buttons.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HatchTasksPanelProps {
|
||||
tasks: HatchTask[];
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
/** Called when user clicks "Create Post" action */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all tasks are complete and user clicks "Hatch" */
|
||||
onHatch: () => void;
|
||||
/** Whether hatching is in progress */
|
||||
isHatching?: boolean;
|
||||
}
|
||||
|
||||
// ─── Task Row Component ───────────────────────────────────────────────────────
|
||||
|
||||
interface TaskRowProps {
|
||||
task: HatchTask;
|
||||
onOpenPostModal: () => void;
|
||||
}
|
||||
|
||||
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
window.open(task.actionTarget, '_blank', 'noopener,noreferrer');
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
onOpenPostModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = task.required > 1
|
||||
? Math.round((task.current / task.required) * 100)
|
||||
: task.completed ? 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 p-4 rounded-xl border transition-all",
|
||||
task.completed
|
||||
? "bg-emerald-500/5 border-emerald-500/20"
|
||||
: "bg-card/60 border-border hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className={cn(
|
||||
"size-10 rounded-full flex items-center justify-center shrink-0",
|
||||
task.completed
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{task.completed ? (
|
||||
<Check className="size-5" />
|
||||
) : task.required > 1 ? (
|
||||
<span className="text-sm font-medium">{task.current}/{task.required}</span>
|
||||
) : (
|
||||
<span className="text-lg">○</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className={cn(
|
||||
"font-medium",
|
||||
task.completed && "text-emerald-600 dark:text-emerald-400"
|
||||
)}>
|
||||
{task.name}
|
||||
</h4>
|
||||
{task.completed && (
|
||||
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
|
||||
{/* Progress bar for multi-step tasks */}
|
||||
{task.required > 1 && !task.completed && (
|
||||
<Progress value={progress} className="h-1.5 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAction}
|
||||
className="shrink-0 gap-2"
|
||||
>
|
||||
{task.actionLabel}
|
||||
{task.action === 'external_link' ? (
|
||||
<ExternalLink className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HatchTasksPanel({
|
||||
tasks,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
onOpenPostModal,
|
||||
onHatch,
|
||||
isHatching = false,
|
||||
}: HatchTasksPanelProps) {
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.length;
|
||||
const overallProgress = Math.round((completedCount / totalTasks) * 100);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🥚</span>
|
||||
Hatch Tasks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Complete these tasks to hatch your Blobbi
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-base px-3 py-1">
|
||||
{completedCount}/{totalTasks}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Overall progress */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">Overall progress</span>
|
||||
<span className="font-medium">{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-2" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tasks.map(task => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hatch button - only visible when all tasks complete */}
|
||||
{allCompleted && (
|
||||
<div className="pt-4 border-t border-border mt-4">
|
||||
<Button
|
||||
onClick={onHatch}
|
||||
disabled={isHatching}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
{isHatching ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
Hatching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl">🐣</span>
|
||||
Hatch Your Blobbi!
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// src/blobbi/actions/components/InlineMusicPlayer.tsx
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Music, Play, Pause, RotateCcw, MoreHorizontal, Loader2, AlertCircle, X, Volume2, VolumeX } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useAudioPlayback } from '../hooks/useAudioPlayback';
|
||||
import type { AudioSource } from './PlayMusicModal';
|
||||
|
||||
// Re-export for external use
|
||||
export type { AudioSource as MusicTrackSource } from './PlayMusicModal';
|
||||
|
||||
interface InlineMusicPlayerProps {
|
||||
/** The selected track source */
|
||||
source: AudioSource;
|
||||
/** Called when user wants to change the track */
|
||||
onChangeTrack: () => void;
|
||||
/** Called when user closes the player */
|
||||
onClose: () => void;
|
||||
/** Called when playback starts (for Blobbi reaction state) */
|
||||
onPlaybackStart?: () => void;
|
||||
/** Called when playback stops/pauses (for Blobbi reaction state) */
|
||||
onPlaybackStop?: () => void;
|
||||
/** Whether the action has been published (playback only starts after publish) */
|
||||
isPublished: boolean;
|
||||
/** Whether publishing is in progress */
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InlineMusicPlayer({
|
||||
source,
|
||||
onChangeTrack,
|
||||
onClose,
|
||||
onPlaybackStart,
|
||||
onPlaybackStop,
|
||||
isPublished,
|
||||
isPublishing,
|
||||
}: InlineMusicPlayerProps) {
|
||||
const {
|
||||
state: playbackState,
|
||||
error: playbackError,
|
||||
load,
|
||||
toggle,
|
||||
restart,
|
||||
stop,
|
||||
isPlaying,
|
||||
volume,
|
||||
setVolume,
|
||||
cleanup,
|
||||
} = useAudioPlayback({
|
||||
onEnded: () => {
|
||||
onPlaybackStop?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-start playback when first published (idle -> playing)
|
||||
// Note: 'stopped' state is NOT included here - stop is a terminal state
|
||||
// that requires explicit user action (play button) to restart
|
||||
useEffect(() => {
|
||||
if (isPublished && playbackState === 'idle') {
|
||||
load(source.url, true);
|
||||
onPlaybackStart?.();
|
||||
}
|
||||
}, [isPublished, playbackState, source.url, load, onPlaybackStart]);
|
||||
|
||||
// Force reload when source URL changes while already playing/paused
|
||||
useEffect(() => {
|
||||
// Only trigger reload if we're in an active playback state with a different URL
|
||||
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
|
||||
// The load function will check if URL changed and reload if needed
|
||||
load(source.url, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to source.url changes
|
||||
}, [source.url]);
|
||||
|
||||
// Notify on playback state changes
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
onPlaybackStart?.();
|
||||
} else if (playbackState === 'paused' || playbackState === 'stopped') {
|
||||
onPlaybackStop?.();
|
||||
}
|
||||
}, [isPlaying, playbackState, onPlaybackStart, onPlaybackStop]);
|
||||
|
||||
// Cleanup on close
|
||||
const handleClose = useCallback(() => {
|
||||
stop();
|
||||
cleanup();
|
||||
onPlaybackStop?.();
|
||||
onClose();
|
||||
}, [stop, cleanup, onPlaybackStop, onClose]);
|
||||
|
||||
// Handle play/pause toggle
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (playbackState === 'idle' || playbackState === 'stopped') {
|
||||
load(source.url, true);
|
||||
} else {
|
||||
await toggle();
|
||||
}
|
||||
}, [playbackState, source.url, load, toggle]);
|
||||
|
||||
// Track title
|
||||
const trackTitle = source.type === 'builtin'
|
||||
? source.track?.title ?? 'Unknown Track'
|
||||
: source.file?.name ?? 'Uploaded Track';
|
||||
|
||||
const trackArtist = source.type === 'builtin'
|
||||
? source.track?.artist
|
||||
: undefined;
|
||||
|
||||
const isLoading = playbackState === 'loading' || isPublishing;
|
||||
const hasError = playbackState === 'error';
|
||||
|
||||
return (
|
||||
<div className="mx-4 sm:mx-6 mb-4">
|
||||
<div className={cn(
|
||||
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
|
||||
"shadow-sm transition-all",
|
||||
isPlaying && "ring-2 ring-pink-500/30"
|
||||
)}>
|
||||
{/* Main content row */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{/* Music icon / Now Playing indicator */}
|
||||
<div className={cn(
|
||||
"size-10 rounded-lg flex items-center justify-center shrink-0",
|
||||
isPlaying
|
||||
? "bg-pink-500/20"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
<Music className={cn(
|
||||
"size-5",
|
||||
isPlaying ? "text-pink-500 animate-pulse" : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{trackTitle}</p>
|
||||
{trackArtist && (
|
||||
<p className="text-xs text-muted-foreground truncate">{trackArtist}</p>
|
||||
)}
|
||||
{!trackArtist && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isPlaying ? 'Now playing...' : isPublishing ? 'Starting...' : 'Ready to play'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Play/Pause button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || !isPublished}
|
||||
className="size-9 rounded-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Restart button - only show when actively playing or paused */}
|
||||
{isPublished && (playbackState === 'playing' || playbackState === 'paused') && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
restart();
|
||||
}}
|
||||
className="size-9 rounded-full"
|
||||
title="Restart from beginning"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Volume control */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-9 rounded-full"
|
||||
title={volume === 0 ? 'Unmute' : 'Volume'}
|
||||
>
|
||||
{volume === 0 ? (
|
||||
<VolumeX className="size-4" />
|
||||
) : (
|
||||
<Volume2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="w-32 p-3"
|
||||
>
|
||||
<Slider
|
||||
value={[volume * 100]}
|
||||
onValueChange={([val]) => setVolume(val / 100)}
|
||||
max={100}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Change track button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onChangeTrack}
|
||||
disabled={isPublishing}
|
||||
className="size-9 rounded-full"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isPublishing}
|
||||
className="size-9 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && playbackError && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<p className="text-xs">{playbackError.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// src/blobbi/actions/components/InlineSingCard.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Mic,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
FileText,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useAudioPlayback } from '../hooks/useAudioPlayback';
|
||||
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'error';
|
||||
|
||||
interface InlineSingCardProps {
|
||||
/** Called when user confirms the singing action (publish the action) */
|
||||
onConfirm: () => Promise<void>;
|
||||
/** Called when user closes the sing card */
|
||||
onClose: () => void;
|
||||
/** Called when recording starts (for Blobbi reaction) */
|
||||
onRecordingStart?: () => void;
|
||||
/** Called when recording stops (for Blobbi reaction) */
|
||||
onRecordingStop?: () => void;
|
||||
/** Whether publishing is in progress */
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
// ─── MIME Type Selection ──────────────────────────────────────────────────────
|
||||
|
||||
const AUDIO_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
] as const;
|
||||
|
||||
function getSupportedAudioMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const mimeType of AUDIO_MIME_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InlineSingCard({
|
||||
onConfirm,
|
||||
onClose,
|
||||
onRecordingStart,
|
||||
onRecordingStop,
|
||||
isPublishing,
|
||||
}: InlineSingCardProps) {
|
||||
// Recording state
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
|
||||
const [recordingError, setRecordingError] = useState<string | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
|
||||
// Lyrics state
|
||||
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
// Refs
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const actualMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Audio playback for preview
|
||||
const {
|
||||
state: playbackState,
|
||||
error: playbackError,
|
||||
load: loadAudio,
|
||||
toggle: togglePlayback,
|
||||
stop: stopPlayback,
|
||||
isPlaying,
|
||||
cleanup: cleanupPlayback,
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup all resources
|
||||
const cleanupAll = useCallback(() => {
|
||||
// Stop timer
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop media recorder
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorderRef.current.stop();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Cleanup playback
|
||||
cleanupPlayback();
|
||||
|
||||
// Revoke URL
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
}, [audioUrl, cleanupPlayback]);
|
||||
|
||||
// Reset recording
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanupAll();
|
||||
setRecordingState('idle');
|
||||
setRecordingError(null);
|
||||
setRecordingDuration(0);
|
||||
setAudioUrl(null);
|
||||
chunksRef.current = [];
|
||||
actualMimeTypeRef.current = undefined;
|
||||
// Keep lyrics
|
||||
}, [cleanupAll]);
|
||||
|
||||
// Check browser support
|
||||
const checkRecordingSupport = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
if (!navigator.mediaDevices) return false;
|
||||
if (!navigator.mediaDevices.getUserMedia) return false;
|
||||
if (typeof MediaRecorder === 'undefined') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!checkRecordingSupport()) {
|
||||
setRecordingError('Audio recording is not supported in this browser.');
|
||||
setRecordingState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingState('requesting');
|
||||
setRecordingError(null);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
|
||||
// Get supported MIME type
|
||||
const supportedMimeType = getSupportedAudioMimeType();
|
||||
|
||||
// Create MediaRecorder
|
||||
let mediaRecorder: MediaRecorder;
|
||||
if (supportedMimeType) {
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
|
||||
} else {
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
}
|
||||
|
||||
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
|
||||
const blob = new Blob(chunksRef.current, { type: blobMimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setRecordingState('recorded');
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
setRecordingError('Recording failed. Please try again.');
|
||||
setRecordingState('error');
|
||||
};
|
||||
|
||||
mediaRecorder.start(100);
|
||||
setRecordingState('recording');
|
||||
setRecordingDuration(0);
|
||||
|
||||
// Notify parent that recording started (for Blobbi reaction)
|
||||
onRecordingStart?.();
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
setRecordingError('Microphone access was denied.');
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setRecordingError('No microphone found.');
|
||||
} else {
|
||||
setRecordingError(err.message);
|
||||
}
|
||||
} else {
|
||||
setRecordingError('Failed to access microphone.');
|
||||
}
|
||||
setRecordingState('error');
|
||||
}
|
||||
}, [onRecordingStart]);
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
// Notify parent that recording stopped (for Blobbi reaction)
|
||||
onRecordingStop?.();
|
||||
}, [onRecordingStop]);
|
||||
|
||||
// Handle preview playback
|
||||
const handlePreview = useCallback(() => {
|
||||
if (!audioUrl) return;
|
||||
|
||||
if (playbackState === 'idle') {
|
||||
loadAudio(audioUrl, true);
|
||||
} else {
|
||||
togglePlayback();
|
||||
}
|
||||
}, [audioUrl, playbackState, loadAudio, togglePlayback]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(async () => {
|
||||
stopPlayback();
|
||||
await onConfirm();
|
||||
// After successful publish, close the card
|
||||
onClose();
|
||||
}, [stopPlayback, onConfirm, onClose]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback(() => {
|
||||
cleanupAll();
|
||||
onClose();
|
||||
}, [cleanupAll, onClose]);
|
||||
|
||||
// Handle lyrics toggle
|
||||
const handleLyricsToggle = useCallback(() => {
|
||||
if (!currentLyrics && !showLyrics) {
|
||||
// Generate lyrics on first open
|
||||
setCurrentLyrics(getRandomLyrics());
|
||||
}
|
||||
setShowLyrics(!showLyrics);
|
||||
}, [currentLyrics, showLyrics]);
|
||||
|
||||
// Get new lyrics
|
||||
const handleNewLyrics = useCallback(() => {
|
||||
setCurrentLyrics(getRandomLyrics());
|
||||
}, []);
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const hasRecording = recordingState === 'recorded';
|
||||
const isRecording = recordingState === 'recording';
|
||||
const canConfirm = hasRecording && !isPublishing;
|
||||
|
||||
return (
|
||||
<div className="mx-4 sm:mx-6 mb-4">
|
||||
<div className={cn(
|
||||
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
|
||||
"shadow-sm transition-all",
|
||||
isRecording && "ring-2 ring-red-500/30"
|
||||
)}>
|
||||
{/* Lyrics panel (expands upward visually by being above controls) */}
|
||||
{showLyrics && currentLyrics && (
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{currentLyrics.title}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleNewLyrics}
|
||||
className="size-7 rounded-full"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50 text-sm leading-relaxed whitespace-pre-line max-h-32 overflow-y-auto">
|
||||
{currentLyrics.lines.join('\n')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status row (recording/recorded info) */}
|
||||
{(isRecording || hasRecording) && (
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isRecording && (
|
||||
<>
|
||||
<div className="size-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-sm font-mono font-medium text-red-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Recording...</span>
|
||||
</>
|
||||
)}
|
||||
{hasRecording && !isRecording && (
|
||||
<>
|
||||
<Check className="size-4 text-purple-500" />
|
||||
<span className="text-sm font-mono font-medium text-purple-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Recorded</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{(recordingError || playbackError) && (
|
||||
<div className="px-3 pt-2">
|
||||
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<p className="text-xs">{recordingError || playbackError?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main controls row */}
|
||||
<div className="flex items-center justify-between gap-2 p-3">
|
||||
{/* Left: Lyrics button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant={showLyrics ? "secondary" : "ghost"}
|
||||
onClick={handleLyricsToggle}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Center: Record/Stop button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isRecording && !hasRecording && (
|
||||
<Button
|
||||
onClick={startRecording}
|
||||
disabled={isPublishing}
|
||||
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
<Mic className="size-4 mr-2" />
|
||||
Sing
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<Button
|
||||
onClick={stopRecording}
|
||||
variant="destructive"
|
||||
className="rounded-full px-6"
|
||||
>
|
||||
<Square className="size-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasRecording && !isRecording && (
|
||||
<>
|
||||
<Button
|
||||
onClick={resetRecording}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-10 rounded-full"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4 mr-2" />
|
||||
)}
|
||||
{isPublishing ? 'Singing...' : 'Sing for Blobbi'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Preview button (when recording exists) */}
|
||||
{hasRecording ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handlePreview}
|
||||
disabled={isPublishing}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
/* Close button when no recording */
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
className="size-10 rounded-full shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close button row when recording exists */}
|
||||
{hasRecording && (
|
||||
<div className="px-3 pb-3 pt-0 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isPublishing}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// src/blobbi/actions/components/PlayMusicModal.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Music, Upload, Play, Pause, Check, Loader2, Volume2, X, AlertCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
getAllBuiltInTracks,
|
||||
formatTrackDuration,
|
||||
type BuiltInTrack,
|
||||
} from '../lib/blobbi-builtin-tracks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Audio source for the music player
|
||||
*/
|
||||
export type AudioSource =
|
||||
| { type: 'builtin'; track: BuiltInTrack; url: string }
|
||||
| { type: 'uploaded'; file: File; url: string };
|
||||
|
||||
interface PlayMusicModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called with the selected audio source when user confirms */
|
||||
onConfirm: (source: AudioSource) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PlayMusicModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: PlayMusicModalProps) {
|
||||
const [selectedSource, setSelectedSource] = useState<AudioSource | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [builtInError, setBuiltInError] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const builtInTracks = getAllBuiltInTracks();
|
||||
|
||||
// Cleanup audio on unmount or modal close
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
// Revoke object URL if it was an uploaded file
|
||||
if (selectedSource?.type === 'uploaded') {
|
||||
URL.revokeObjectURL(selectedSource.url);
|
||||
}
|
||||
};
|
||||
}, [selectedSource]);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedSource(null);
|
||||
setIsPlaying(false);
|
||||
setUploadError(null);
|
||||
setBuiltInError(null);
|
||||
currentAudioUrlRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Handle selecting a built-in track
|
||||
const handleSelectBuiltIn = useCallback((track: BuiltInTrack) => {
|
||||
// Stop current playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
// Revoke previous URL if uploaded
|
||||
if (selectedSource?.type === 'uploaded') {
|
||||
URL.revokeObjectURL(selectedSource.url);
|
||||
}
|
||||
|
||||
setSelectedSource({ type: 'builtin', track, url: track.path });
|
||||
setBuiltInError(null);
|
||||
}, [selectedSource]);
|
||||
|
||||
// Handle file upload
|
||||
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/mp4'];
|
||||
if (!validTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|m4a)$/i)) {
|
||||
setUploadError('Please upload an MP3, WAV, OGG, or M4A file.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
setUploadError('File is too large. Maximum size is 10MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop current playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
// Revoke previous URL if uploaded
|
||||
if (selectedSource?.type === 'uploaded') {
|
||||
URL.revokeObjectURL(selectedSource.url);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
setSelectedSource({ type: 'uploaded', file, url });
|
||||
setUploadError(null);
|
||||
}, [selectedSource]);
|
||||
|
||||
// Track the current audio source URL to detect changes
|
||||
const currentAudioUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Handle play/pause preview
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (!selectedSource) return;
|
||||
|
||||
const audioUrl = selectedSource.type === 'builtin'
|
||||
? selectedSource.track.path
|
||||
: selectedSource.url;
|
||||
|
||||
// Check if we need to create a new Audio instance (source changed or first time)
|
||||
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
|
||||
|
||||
if (needsNewAudio) {
|
||||
// Stop and cleanup old audio if exists
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
}
|
||||
|
||||
// Create new Audio instance with the correct source
|
||||
audioRef.current = new Audio(audioUrl);
|
||||
currentAudioUrlRef.current = audioUrl;
|
||||
|
||||
audioRef.current.onended = () => setIsPlaying(false);
|
||||
audioRef.current.onerror = () => {
|
||||
if (selectedSource.type === 'builtin') {
|
||||
setBuiltInError('This track is not available yet. Try uploading your own music!');
|
||||
}
|
||||
setIsPlaying(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlaying && !needsNewAudio) {
|
||||
// Pause current playback
|
||||
audioRef.current?.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// Start playback (either new source or resuming)
|
||||
audioRef.current?.play().catch(() => {
|
||||
if (selectedSource.type === 'builtin') {
|
||||
setBuiltInError('This track is not available yet. Try uploading your own music!');
|
||||
}
|
||||
setIsPlaying(false);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [selectedSource, isPlaying]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!selectedSource) return;
|
||||
|
||||
// Stop playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
onConfirm(selectedSource);
|
||||
}, [selectedSource, onConfirm]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const selectedName = selectedSource?.type === 'builtin'
|
||||
? selectedSource.track.title
|
||||
: selectedSource?.type === 'uploaded'
|
||||
? selectedSource.file.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-gradient-to-br from-pink-500/20 to-pink-500/5 flex items-center justify-center">
|
||||
<Music className="size-5 text-pink-500" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Play Music</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a track to play for your Blobbi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<Tabs defaultValue="builtin" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="builtin">Built-in</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="upload"
|
||||
disabled
|
||||
className="gap-1.5 data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
|
||||
>
|
||||
Upload
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 font-normal">
|
||||
Soon
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Built-in Tracks Tab */}
|
||||
<TabsContent value="builtin" className="mt-4">
|
||||
<div className="grid gap-2">
|
||||
{builtInTracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
isSelected={selectedSource?.type === 'builtin' && selectedSource.track.id === track.id}
|
||||
onSelect={() => handleSelectBuiltIn(track)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{builtInError && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{builtInError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Upload Tab */}
|
||||
<TabsContent value="upload" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
{/* Upload Area */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"w-full p-8 rounded-xl border-2 border-dashed transition-colors",
|
||||
"hover:border-primary/50 hover:bg-primary/5",
|
||||
"flex flex-col items-center justify-center gap-3",
|
||||
selectedSource?.type === 'uploaded'
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Upload className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Upload Audio File</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
MP3, WAV, OGG, M4A (max 10MB)
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Upload Error */}
|
||||
{uploadError && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<X className="size-4 text-destructive mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-destructive">{uploadError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded File Display */}
|
||||
{selectedSource?.type === 'uploaded' && (
|
||||
<div className="p-4 rounded-xl border bg-card/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Music className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{selectedSource.file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(selectedSource.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<Check className="size-5 text-primary shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t bg-muted/30">
|
||||
{/* Preview Controls */}
|
||||
{selectedSource && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-card border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleTogglePlay}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate text-sm">{selectedName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isPlaying ? 'Now playing...' : 'Click to preview'}
|
||||
</p>
|
||||
</div>
|
||||
{isPlaying && (
|
||||
<Volume2 className="size-4 text-primary animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleClose(false)}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedSource || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Playing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Music className="size-4 mr-2" />
|
||||
Play for Blobbi
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Track Row Component ──────────────────────────────────────────────────────
|
||||
|
||||
interface TrackRowProps {
|
||||
track: BuiltInTrack;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function TrackRow({ track, isSelected, onSelect }: TrackRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-xl text-left transition-all",
|
||||
"border hover:border-primary/30",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "border-border bg-card/60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"size-10 rounded-lg flex items-center justify-center",
|
||||
isSelected ? "bg-primary/20" : "bg-muted"
|
||||
)}>
|
||||
<Music className={cn(
|
||||
"size-5",
|
||||
isSelected ? "text-primary" : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{track.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatTrackDuration(track.durationSeconds)}
|
||||
</span>
|
||||
{isSelected && <Check className="size-4 text-primary" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// src/blobbi/actions/components/SingModal.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SingModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
|
||||
|
||||
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ordered list of MIME types to try for audio recording.
|
||||
* The first supported type will be used.
|
||||
*/
|
||||
const AUDIO_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get the first supported MIME type for MediaRecorder.
|
||||
* Returns undefined if no explicit MIME type is supported (let browser decide).
|
||||
*/
|
||||
function getSupportedAudioMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const mimeType of AUDIO_MIME_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
// No explicit MIME type supported, let browser use default
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SingModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: SingModalProps) {
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// 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) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop media recorder
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Stop audio playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
// Revoke URL
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
}, [audioUrl]);
|
||||
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanup();
|
||||
setRecordingState('idle');
|
||||
setError(null);
|
||||
setPlaybackError(null);
|
||||
setRecordingDuration(0);
|
||||
setAudioUrl(null);
|
||||
chunksRef.current = [];
|
||||
currentPlaybackUrlRef.current = null;
|
||||
actualMimeTypeRef.current = undefined;
|
||||
// Keep lyrics when re-recording so user can sing the same song
|
||||
}, [cleanup]);
|
||||
|
||||
// Handle getting random lyrics
|
||||
const handleRandomLyrics = useCallback(() => {
|
||||
const lyrics = getRandomLyrics();
|
||||
setCurrentLyrics(lyrics);
|
||||
setShowLyrics(true);
|
||||
}, []);
|
||||
|
||||
// Check if browser supports media recording
|
||||
const checkRecordingSupport = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
if (!navigator.mediaDevices) return false;
|
||||
if (!navigator.mediaDevices.getUserMedia) return false;
|
||||
if (typeof MediaRecorder === 'undefined') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!checkRecordingSupport()) {
|
||||
setError('Audio recording is not supported in this browser.');
|
||||
setRecordingState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingState('requesting');
|
||||
setError(null);
|
||||
setPlaybackError(null);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
|
||||
// Get the first supported MIME type using our helper
|
||||
const supportedMimeType = getSupportedAudioMimeType();
|
||||
|
||||
// Create MediaRecorder with or without explicit MIME type
|
||||
let mediaRecorder: MediaRecorder;
|
||||
if (supportedMimeType) {
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
|
||||
} else {
|
||||
// Let browser choose default MIME type
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
}
|
||||
|
||||
// Store the actual MIME type being used (may differ from what we requested)
|
||||
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
// Create blob from chunks using the actual MIME type used by the recorder
|
||||
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
|
||||
const blob = new Blob(chunksRef.current, { type: blobMimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setRecordingState('recorded');
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
setError('Recording failed. Please try again.');
|
||||
setRecordingState('error');
|
||||
};
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start(100); // Collect data every 100ms
|
||||
setRecordingState('recording');
|
||||
setRecordingDuration(0);
|
||||
|
||||
// Start timer
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
setError('Microphone access was denied. Please allow microphone access and try again.');
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setError('No microphone found. Please connect a microphone and try again.');
|
||||
} else {
|
||||
setError(`Failed to access microphone: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
setError('Failed to access microphone. Please try again.');
|
||||
}
|
||||
setRecordingState('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track the current audio URL to detect changes
|
||||
const currentPlaybackUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Play/pause preview
|
||||
const togglePlayback = useCallback(() => {
|
||||
if (!audioUrl) return;
|
||||
|
||||
// Clear previous playback error when attempting to play
|
||||
setPlaybackError(null);
|
||||
|
||||
if (recordingState === 'playing') {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
setRecordingState('recorded');
|
||||
} else {
|
||||
// Check if we need to create a new Audio instance (URL changed or first time)
|
||||
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
|
||||
|
||||
if (needsNewAudio) {
|
||||
// Cleanup old audio if exists
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
}
|
||||
|
||||
// Create new Audio instance with the recorded audio URL
|
||||
audioRef.current = new Audio(audioUrl);
|
||||
currentPlaybackUrlRef.current = audioUrl;
|
||||
audioRef.current.onended = () => setRecordingState('recorded');
|
||||
|
||||
// Handle playback errors with user-visible message
|
||||
audioRef.current.onerror = () => {
|
||||
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
|
||||
setRecordingState('recorded');
|
||||
};
|
||||
}
|
||||
|
||||
audioRef.current?.play()
|
||||
.then(() => {
|
||||
setRecordingState('playing');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to play recording:', err);
|
||||
// Provide user-friendly error message
|
||||
if (err.name === 'NotSupportedError') {
|
||||
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
|
||||
} else if (err.name === 'NotAllowedError') {
|
||||
setPlaybackError('Playback was blocked. Try interacting with the page first.');
|
||||
} else {
|
||||
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
|
||||
}
|
||||
setRecordingState('recorded');
|
||||
});
|
||||
}
|
||||
}, [audioUrl, recordingState]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
onConfirm();
|
||||
}, [onConfirm]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
cleanup();
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange, cleanup]);
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
|
||||
<Mic className="size-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Sing</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Record yourself singing for your Blobbi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 px-6 py-8">
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
{/* Recording Visualization */}
|
||||
<div className={cn(
|
||||
"relative size-40 rounded-full flex items-center justify-center transition-all",
|
||||
recordingState === 'recording' && "animate-pulse",
|
||||
recordingState === 'recording'
|
||||
? "bg-red-500/10 ring-4 ring-red-500/30"
|
||||
: hasRecording
|
||||
? "bg-purple-500/10 ring-4 ring-purple-500/30"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
{/* Animated rings for recording */}
|
||||
{recordingState === 'recording' && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
|
||||
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
"relative size-20 rounded-full flex items-center justify-center",
|
||||
recordingState === 'recording'
|
||||
? "bg-red-500 text-white"
|
||||
: hasRecording
|
||||
? "bg-purple-500 text-white"
|
||||
: "bg-muted-foreground/20"
|
||||
)}>
|
||||
{recordingState === 'requesting' ? (
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
) : recordingState === 'recording' ? (
|
||||
<Mic className="size-8" />
|
||||
) : hasRecording ? (
|
||||
recordingState === 'playing' ? (
|
||||
<Pause className="size-8" />
|
||||
) : (
|
||||
<Play className="size-8 ml-1" />
|
||||
)
|
||||
) : (
|
||||
<MicOff className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration / Status */}
|
||||
<div className="text-center">
|
||||
{recordingState === 'idle' && (
|
||||
<p className="text-muted-foreground">Tap the button below to start recording</p>
|
||||
)}
|
||||
{recordingState === 'requesting' && (
|
||||
<p className="text-muted-foreground">Requesting microphone access...</p>
|
||||
)}
|
||||
{recordingState === 'recording' && (
|
||||
<>
|
||||
<p className="text-3xl font-mono font-bold text-red-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
|
||||
</>
|
||||
)}
|
||||
{hasRecording && (
|
||||
<>
|
||||
<p className="text-3xl font-mono font-bold text-purple-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{recordingState === 'error' && (
|
||||
<p className="text-destructive">Recording failed</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playback Error Message (non-fatal, recording still works) */}
|
||||
{playbackError && (
|
||||
<div className="w-full p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{playbackError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lyrics Helper */}
|
||||
<div className="w-full">
|
||||
{!currentLyrics ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRandomLyrics}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Need lyrics? Get random lyrics
|
||||
</Button>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
className="w-full flex items-center justify-between p-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 text-purple-500" />
|
||||
<span className="font-medium text-sm">{currentLyrics.title}</span>
|
||||
</div>
|
||||
{showLyrics ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showLyrics && (
|
||||
<div className="px-3 pb-3 pt-0">
|
||||
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
|
||||
{currentLyrics.lines.join('\n')}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRandomLyrics}
|
||||
className="w-full mt-2 gap-2 text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Get different lyrics
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recording Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{recordingState === 'idle' || recordingState === 'error' ? (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={startRecording}
|
||||
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
<Mic className="size-5 mr-2" />
|
||||
Start Recording
|
||||
</Button>
|
||||
) : recordingState === 'recording' ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="destructive"
|
||||
onClick={stopRecording}
|
||||
className="rounded-full px-8"
|
||||
>
|
||||
<Square className="size-5 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
) : hasRecording ? (
|
||||
<>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={togglePlayback}
|
||||
className="rounded-full"
|
||||
>
|
||||
{recordingState === 'playing' ? (
|
||||
<>
|
||||
<Pause className="size-5 mr-2" />
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-5 mr-2" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={resetRecording}
|
||||
className="rounded-full"
|
||||
>
|
||||
<RotateCcw className="size-5 mr-2" />
|
||||
Re-record
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t bg-muted/30">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleClose(false)}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!hasRecording || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Singing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="size-4 mr-2" />
|
||||
Sing for Blobbi
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// src/blobbi/actions/components/StartEvolutionDialog.tsx
|
||||
|
||||
/**
|
||||
* Dialog for confirming start of evolution.
|
||||
*
|
||||
* Evolution is simpler than incubation:
|
||||
* - Only baby Blobbis can evolve
|
||||
* - Shows restart confirmation if already evolving
|
||||
* - Otherwise shows normal start confirmation
|
||||
*/
|
||||
|
||||
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StartEvolutionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The companion to start evolving */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called when confirmed */
|
||||
onConfirm: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function StartEvolutionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StartEvolutionDialogProps) {
|
||||
// Check if the current Blobbi is already evolving
|
||||
const isAlreadyEvolving = companion?.state === 'evolving';
|
||||
|
||||
// Determine title and description based on state
|
||||
const getDialogContent = () => {
|
||||
if (isAlreadyEvolving) {
|
||||
return {
|
||||
title: 'Restart Evolution?',
|
||||
icon: <AlertTriangle className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
|
||||
<strong>reset all task progress</strong> and begin from the beginning.
|
||||
<br /><br />
|
||||
Are you sure you want to restart?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Restart Evolution',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Start Evolution',
|
||||
icon: <Sparkles className="size-5 text-primary" />,
|
||||
description: (
|
||||
<>
|
||||
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
|
||||
Complete all the tasks to evolve your baby Blobbi into an adult!
|
||||
<br /><br />
|
||||
Ready to begin?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Start Evolution',
|
||||
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
|
||||
};
|
||||
};
|
||||
|
||||
const content = getDialogContent();
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
{content.icon}
|
||||
{content.title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{content.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={content.buttonClass}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
content.buttonText
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// src/blobbi/actions/components/StartIncubationDialog.tsx
|
||||
|
||||
/**
|
||||
* Dialog for confirming start of incubation.
|
||||
*
|
||||
* Determines the mode and passes it explicitly to the confirm callback:
|
||||
* - 'start': Normal start, no other Blobbi incubating
|
||||
* - 'restart': Restart same Blobbi (already incubating)
|
||||
* - 'switch': Stop another Blobbi first, then start this one
|
||||
*
|
||||
* The mode is determined by UI state, NOT auto-detected by the hook.
|
||||
* This makes the flow explicit and predictable.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StartIncubationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The companion to start incubating */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** All companions in the collection (to check for other incubating Blobbis) */
|
||||
companions?: BlobbiCompanion[];
|
||||
/** Called with explicit mode and optional stopOtherD when confirmed */
|
||||
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function StartIncubationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
companions = [],
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StartIncubationDialogProps) {
|
||||
// Check if the current Blobbi is already in a task state
|
||||
const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving';
|
||||
|
||||
// Check if another Blobbi (not this one) is currently incubating
|
||||
const otherIncubatingBlobbi = useMemo(() => {
|
||||
if (!companion) return null;
|
||||
return companions.find(c =>
|
||||
c.d !== companion.d &&
|
||||
c.state === 'incubating' &&
|
||||
c.stage === 'egg'
|
||||
) ?? null;
|
||||
}, [companion, companions]);
|
||||
|
||||
// Determine the mode based on current state
|
||||
const mode: StartIncubationMode = useMemo(() => {
|
||||
if (isAlreadyInTaskState) return 'restart';
|
||||
if (otherIncubatingBlobbi) return 'switch';
|
||||
return 'start';
|
||||
}, [isAlreadyInTaskState, otherIncubatingBlobbi]);
|
||||
|
||||
// Handle confirm with explicit mode
|
||||
const handleConfirm = () => {
|
||||
if (mode === 'switch' && otherIncubatingBlobbi) {
|
||||
onConfirm(mode, otherIncubatingBlobbi.d);
|
||||
} else {
|
||||
onConfirm(mode);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine title and description based on mode
|
||||
const getDialogContent = () => {
|
||||
if (mode === 'restart') {
|
||||
return {
|
||||
title: 'Restart Incubation?',
|
||||
icon: <AlertTriangle className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
Your Blobbi is already {companion?.state}. Starting over will{' '}
|
||||
<strong>reset all task progress</strong> and begin from the beginning.
|
||||
<br /><br />
|
||||
Are you sure you want to restart?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Restart Incubation',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'switch') {
|
||||
return {
|
||||
title: 'Switch Incubation?',
|
||||
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
|
||||
Only one Blobbi can incubate at a time.
|
||||
<br /><br />
|
||||
Starting incubation for <strong>{companion?.name}</strong> will{' '}
|
||||
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
|
||||
reset their task progress.
|
||||
<br /><br />
|
||||
Do you want to switch?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Switch & Start',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Start Incubation',
|
||||
icon: null,
|
||||
description: (
|
||||
<>
|
||||
Starting incubation begins your Blobbi's hatching journey.
|
||||
Complete all the tasks to hatch your egg into a baby Blobbi!
|
||||
<br /><br />
|
||||
Ready to begin?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Start Incubation',
|
||||
buttonClass: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const content = getDialogContent();
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
{content.icon}
|
||||
{content.title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{content.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={content.buttonClass}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
content.buttonText
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// src/blobbi/actions/components/TasksPanel.tsx
|
||||
|
||||
/**
|
||||
* Generic UI component for displaying task progress.
|
||||
* Shows a list of tasks with progress indicators and action buttons.
|
||||
* Used for both hatch and evolve tasks.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TasksPanelProps {
|
||||
tasks: HatchTask[];
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
/** Called when user clicks "Create Post" action */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all tasks are complete and user clicks the complete button */
|
||||
onComplete: () => void;
|
||||
/** Whether completion is in progress */
|
||||
isCompleting?: boolean;
|
||||
/** Emoji to show in header */
|
||||
emoji: string;
|
||||
/** Title for the tasks panel */
|
||||
title: string;
|
||||
/** Description for the tasks panel */
|
||||
description: string;
|
||||
/** Label for the complete button */
|
||||
completeLabel: string;
|
||||
/** Label while completing */
|
||||
completingLabel: string;
|
||||
/** Emoji for complete button */
|
||||
completeEmoji: string;
|
||||
}
|
||||
|
||||
// ─── Task Row Component ───────────────────────────────────────────────────────
|
||||
|
||||
interface TaskRowProps {
|
||||
task: HatchTask;
|
||||
onOpenPostModal: () => void;
|
||||
}
|
||||
|
||||
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
const navigate = useNavigate();
|
||||
const isDynamic = task.type === 'dynamic';
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
window.open(task.actionTarget, '_blank', 'noopener,noreferrer');
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
onOpenPostModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = task.required > 1
|
||||
? Math.round((task.current / task.required) * 100)
|
||||
: task.completed ? 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border transition-all overflow-hidden",
|
||||
task.completed
|
||||
? "bg-emerald-500/5 border-emerald-500/20"
|
||||
: isDynamic
|
||||
? "bg-amber-500/5 border-amber-500/20"
|
||||
: "bg-card/60 border-border hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Status + Task info */}
|
||||
<div className="flex items-start sm:items-center gap-3 sm:contents">
|
||||
{/* Status indicator */}
|
||||
<div className={cn(
|
||||
"size-8 sm:size-10 rounded-full flex items-center justify-center shrink-0",
|
||||
task.completed
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: isDynamic
|
||||
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{task.completed ? (
|
||||
<Check className="size-4 sm:size-5" />
|
||||
) : isDynamic ? (
|
||||
<AlertCircle className="size-4 sm:size-5" />
|
||||
) : task.required > 1 ? (
|
||||
<span className="text-xs sm:text-sm font-medium">{task.current}/{task.required}</span>
|
||||
) : (
|
||||
<span className="text-base sm:text-lg">○</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mb-0.5 sm:mb-1">
|
||||
<h4 className={cn(
|
||||
"font-medium text-sm sm:text-base break-words",
|
||||
task.completed && "text-emerald-600 dark:text-emerald-400",
|
||||
isDynamic && !task.completed && "text-amber-600 dark:text-amber-400"
|
||||
)}>
|
||||
{task.name}
|
||||
</h4>
|
||||
{task.completed && (
|
||||
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs shrink-0">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
{isDynamic && !task.completed && (
|
||||
<Badge variant="secondary" className="bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs shrink-0">
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground break-words">
|
||||
{task.description}
|
||||
</p>
|
||||
|
||||
{/* Progress bar for multi-step tasks (not for dynamic stat tasks) */}
|
||||
{task.required > 1 && !task.completed && !isDynamic && (
|
||||
<Progress value={progress} className="h-1.5 mt-2" />
|
||||
)}
|
||||
|
||||
{/* Dynamic task hint */}
|
||||
{isDynamic && !task.completed && (
|
||||
<p className="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">
|
||||
Lowest stat: {task.current}% (need {task.required}%+)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button - full width on mobile when present */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAction}
|
||||
className="shrink-0 gap-2 w-full sm:w-auto mt-1 sm:mt-0"
|
||||
>
|
||||
<span className="truncate">{task.actionLabel}</span>
|
||||
{task.action === 'external_link' ? (
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function TasksPanel({
|
||||
tasks,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
onOpenPostModal,
|
||||
onComplete,
|
||||
isCompleting = false,
|
||||
emoji,
|
||||
title,
|
||||
description,
|
||||
completeLabel,
|
||||
completingLabel,
|
||||
completeEmoji,
|
||||
}: TasksPanelProps) {
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.length;
|
||||
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent overflow-hidden">
|
||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
||||
<div className="flex items-start sm:items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<span className="text-xl sm:text-2xl shrink-0">{emoji}</span>
|
||||
<span className="break-words">{title}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm break-words">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm sm:text-base px-2 sm:px-3 py-0.5 sm:py-1 shrink-0">
|
||||
{completedCount}/{totalTasks}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Overall progress */}
|
||||
<div className="mt-3 sm:mt-4">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm mb-2">
|
||||
<span className="text-muted-foreground">Overall progress</span>
|
||||
<span className="font-medium">{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-2" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2 sm:space-y-3 px-3 sm:px-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tasks.map(task => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Complete button - only visible when all tasks complete */}
|
||||
{allCompleted && (
|
||||
<div className="pt-4 border-t border-border mt-4">
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isCompleting}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
{isCompleting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{completingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl">{completeEmoji}</span>
|
||||
{completeLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// src/blobbi/actions/hooks/useActiveTaskProcess.ts
|
||||
|
||||
/**
|
||||
* Central abstraction for the active task process (hatch or evolve).
|
||||
*
|
||||
* This hook consolidates all scattered if/else logic for determining:
|
||||
* - Which process is active (incubating vs evolving)
|
||||
* - Which tasks to use (hatch vs evolve)
|
||||
* - Thresholds and configuration
|
||||
* - Badge-related computed values
|
||||
*
|
||||
* ARCHITECTURE RULES:
|
||||
* - Computed tasks remain the source of truth
|
||||
* - Tags are cache only for PERSISTENT tasks
|
||||
* - Dynamic tasks are NEVER persisted
|
||||
* - Badge counts ALL incomplete tasks (persistent + dynamic)
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
|
||||
import type { EvolveTasksResult } from './useEvolveTasks';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The type of task process currently active */
|
||||
export type TaskProcessType = 'hatch' | 'evolve' | null;
|
||||
|
||||
/**
|
||||
* Configuration for the active task process.
|
||||
* This provides a unified interface regardless of whether
|
||||
* the process is hatch or evolve.
|
||||
*/
|
||||
export interface TaskProcessConfig {
|
||||
/** The type of process ('hatch' | 'evolve' | null) */
|
||||
type: TaskProcessType;
|
||||
/** Whether there is an active task process */
|
||||
isActive: boolean;
|
||||
/** Required interactions threshold for the current process */
|
||||
interactionThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the active task process hook.
|
||||
* Provides unified access to all task-related state.
|
||||
*/
|
||||
export interface ActiveTaskProcessResult {
|
||||
/** Configuration for the current process */
|
||||
config: TaskProcessConfig;
|
||||
|
||||
/** All tasks for the current process (empty if no active process) */
|
||||
tasks: HatchTask[];
|
||||
/** Whether tasks are still loading */
|
||||
isLoading: boolean;
|
||||
/** Whether all tasks (persistent + dynamic) are complete */
|
||||
allCompleted: boolean;
|
||||
/** Whether all persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Whether the dynamic task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
|
||||
/** Refetch function for current tasks */
|
||||
refetch: () => void;
|
||||
|
||||
// ─── Badge-related computed values ───
|
||||
|
||||
/**
|
||||
* Count of ALL remaining incomplete tasks (persistent + dynamic).
|
||||
* This is used for the badge display.
|
||||
* Dynamic tasks ARE counted here but are NEVER synced to tags.
|
||||
*/
|
||||
remainingTasksCount: number;
|
||||
|
||||
/**
|
||||
* Only persistent tasks that are incomplete.
|
||||
* Used for sync logic - dynamic tasks must NEVER be synced.
|
||||
*/
|
||||
incompletePersistentTasks: HatchTask[];
|
||||
|
||||
/**
|
||||
* Only persistent tasks that are complete.
|
||||
* Used for sync logic.
|
||||
*/
|
||||
completedPersistentTasks: HatchTask[];
|
||||
|
||||
/**
|
||||
* Stable string key of completed persistent task IDs.
|
||||
* Used for sync anti-loop protection.
|
||||
*/
|
||||
completedPersistentTaskIds: string;
|
||||
|
||||
/**
|
||||
* Tasks to sync (persistent only, with completion status).
|
||||
* Dynamic tasks are excluded.
|
||||
*/
|
||||
tasksToSync: Array<{ taskId: string; completed: boolean }>;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter tasks to only persistent tasks.
|
||||
* Dynamic tasks must NEVER be synced to tags.
|
||||
*/
|
||||
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'persistent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tasks to only dynamic tasks.
|
||||
*/
|
||||
export function filterDynamicTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'dynamic');
|
||||
}
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook that provides a unified interface for the active task process.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const taskProcess = useActiveTaskProcess(companion, hatchTasks, evolveTasks);
|
||||
*
|
||||
* // Access unified data
|
||||
* taskProcess.config.type // 'hatch' | 'evolve' | null
|
||||
* taskProcess.tasks // current tasks
|
||||
* taskProcess.remainingTasksCount // for badge (includes dynamic)
|
||||
* taskProcess.tasksToSync // for sync (excludes dynamic)
|
||||
* ```
|
||||
*/
|
||||
export function useActiveTaskProcess(
|
||||
companion: BlobbiCompanion | null,
|
||||
hatchTasks: HatchTasksResult,
|
||||
evolveTasks: EvolveTasksResult
|
||||
): ActiveTaskProcessResult {
|
||||
// Determine which process is active
|
||||
const processType = useMemo((): TaskProcessType => {
|
||||
if (!companion) return null;
|
||||
if (companion.state === 'incubating') return 'hatch';
|
||||
if (companion.state === 'evolving') return 'evolve';
|
||||
return null;
|
||||
}, [companion]);
|
||||
|
||||
// Build configuration
|
||||
const config = useMemo((): TaskProcessConfig => {
|
||||
const isActive = processType !== null;
|
||||
const interactionThreshold = processType === 'hatch'
|
||||
? HATCH_REQUIRED_INTERACTIONS
|
||||
: processType === 'evolve'
|
||||
? EVOLVE_REQUIRED_INTERACTIONS
|
||||
: 0;
|
||||
|
||||
return {
|
||||
type: processType,
|
||||
isActive,
|
||||
interactionThreshold,
|
||||
};
|
||||
}, [processType]);
|
||||
|
||||
// Get the active tasks result based on process type
|
||||
const activeResult = useMemo(() => {
|
||||
if (processType === 'hatch') return hatchTasks;
|
||||
if (processType === 'evolve') return evolveTasks;
|
||||
return null;
|
||||
}, [processType, hatchTasks, evolveTasks]);
|
||||
|
||||
// Extract tasks and state from active result
|
||||
const tasks = activeResult?.tasks ?? [];
|
||||
const isLoading = activeResult?.isLoading ?? false;
|
||||
const allCompleted = activeResult?.allCompleted ?? false;
|
||||
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
|
||||
const dynamicTaskComplete = activeResult?.dynamicTaskComplete ?? false;
|
||||
const refetch = activeResult?.refetch ?? (() => {});
|
||||
|
||||
// Compute persistent task list (dynamic tasks computed for badge count directly from tasks array)
|
||||
const persistentTasks = useMemo(() => filterPersistentTasks(tasks), [tasks]);
|
||||
|
||||
// Compute incomplete tasks (for badge - includes BOTH persistent and dynamic)
|
||||
const remainingTasksCount = useMemo(() => {
|
||||
// Count ALL incomplete tasks - persistent AND dynamic
|
||||
// Dynamic tasks are included in badge count but NEVER synced to tags
|
||||
return tasks.filter(t => !t.completed).length;
|
||||
}, [tasks]);
|
||||
|
||||
// Compute persistent task lists for sync
|
||||
const incompletePersistentTasks = useMemo(() =>
|
||||
persistentTasks.filter(t => !t.completed),
|
||||
[persistentTasks]
|
||||
);
|
||||
|
||||
const completedPersistentTasks = useMemo(() =>
|
||||
persistentTasks.filter(t => t.completed),
|
||||
[persistentTasks]
|
||||
);
|
||||
|
||||
// Compute stable string key for completed persistent tasks (anti-loop)
|
||||
const completedPersistentTaskIds = useMemo(() => {
|
||||
if (!completedPersistentTasks.length) return '';
|
||||
return completedPersistentTasks
|
||||
.map(t => t.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
}, [completedPersistentTasks]);
|
||||
|
||||
// Compute tasks to sync (persistent only)
|
||||
// CRITICAL: Dynamic tasks must NEVER be included here
|
||||
const tasksToSync = useMemo(() => {
|
||||
if (!persistentTasks.length) return [];
|
||||
return persistentTasks.map(t => ({
|
||||
taskId: t.id,
|
||||
completed: t.completed,
|
||||
}));
|
||||
}, [persistentTasks]);
|
||||
|
||||
return {
|
||||
config,
|
||||
tasks,
|
||||
isLoading,
|
||||
allCompleted,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
refetch,
|
||||
remainingTasksCount,
|
||||
incompletePersistentTasks,
|
||||
completedPersistentTasks,
|
||||
completedPersistentTaskIds,
|
||||
tasksToSync,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// src/blobbi/actions/hooks/useAudioPlayback.ts
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Audio playback state
|
||||
* - idle: No audio loaded
|
||||
* - loading: Audio is being loaded
|
||||
* - playing: Audio is playing
|
||||
* - paused: Audio is paused (can resume)
|
||||
* - stopped: Audio was stopped (must reload to play again)
|
||||
* - error: An error occurred
|
||||
*/
|
||||
export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
|
||||
|
||||
/**
|
||||
* Audio playback error info
|
||||
*/
|
||||
export interface PlaybackError {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Default volume level (0-1) */
|
||||
const DEFAULT_VOLUME = 0.8;
|
||||
|
||||
/**
|
||||
* Options for the useAudioPlayback hook
|
||||
*/
|
||||
export interface UseAudioPlaybackOptions {
|
||||
/** Called when playback ends naturally */
|
||||
onEnded?: () => void;
|
||||
/** Called when an error occurs */
|
||||
onError?: (error: PlaybackError) => void;
|
||||
/** Initial volume level (0-1), defaults to 0.8 */
|
||||
initialVolume?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useAudioPlayback hook
|
||||
*/
|
||||
export interface UseAudioPlaybackReturn {
|
||||
/** Current playback state */
|
||||
state: PlaybackState;
|
||||
/** Current error (if any) */
|
||||
error: PlaybackError | null;
|
||||
/** Current audio URL being played */
|
||||
currentUrl: string | null;
|
||||
/** Load and optionally start playing an audio URL */
|
||||
load: (url: string, autoplay?: boolean) => void;
|
||||
/** Play the current audio */
|
||||
play: () => Promise<void>;
|
||||
/** Pause the current audio */
|
||||
pause: () => void;
|
||||
/** Stop playback and reset */
|
||||
stop: () => void;
|
||||
/** Restart playback from the beginning */
|
||||
restart: () => Promise<void>;
|
||||
/** Toggle play/pause */
|
||||
toggle: () => Promise<void>;
|
||||
/** Whether audio is currently playing */
|
||||
isPlaying: boolean;
|
||||
/** Current volume level (0-1) */
|
||||
volume: number;
|
||||
/** Set volume level (0-1) */
|
||||
setVolume: (volume: number) => void;
|
||||
/** Cleanup function to release resources */
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable hook for audio playback.
|
||||
* Handles Audio element lifecycle, error handling, and state management.
|
||||
*/
|
||||
export function useAudioPlayback(options: UseAudioPlaybackOptions = {}): UseAudioPlaybackReturn {
|
||||
const { onEnded, onError, initialVolume = DEFAULT_VOLUME } = options;
|
||||
|
||||
const [state, setState] = useState<PlaybackState>('idle');
|
||||
const [error, setError] = useState<PlaybackError | null>(null);
|
||||
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
|
||||
const [volume, setVolumeState] = useState<number>(initialVolume);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const currentUrlRef = useRef<string | null>(null);
|
||||
const volumeRef = useRef<number>(initialVolume);
|
||||
|
||||
// Cleanup audio element
|
||||
const cleanup = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current.oncanplay = null;
|
||||
audioRef.current.onplaying = null;
|
||||
audioRef.current = null;
|
||||
}
|
||||
currentUrlRef.current = null;
|
||||
setState('idle');
|
||||
setCurrentUrl(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
// Load audio from URL
|
||||
const load = useCallback((url: string, autoplay = false) => {
|
||||
// If same URL, don't reload
|
||||
if (currentUrlRef.current === url && audioRef.current) {
|
||||
if (autoplay) {
|
||||
audioRef.current.play().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup previous audio
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current.oncanplay = null;
|
||||
audioRef.current.onplaying = null;
|
||||
}
|
||||
|
||||
setState('loading');
|
||||
setError(null);
|
||||
setCurrentUrl(url);
|
||||
currentUrlRef.current = url;
|
||||
|
||||
const audio = new Audio(url);
|
||||
audio.volume = volumeRef.current; // Apply current volume to new audio
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.oncanplay = () => {
|
||||
if (autoplay) {
|
||||
audio.play().catch((err) => {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err.name,
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
});
|
||||
} else {
|
||||
setState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
audio.onplaying = () => {
|
||||
setState('playing');
|
||||
};
|
||||
|
||||
audio.onpause = () => {
|
||||
if (state === 'playing') {
|
||||
setState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
setState('paused');
|
||||
onEnded?.();
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
const playbackError: PlaybackError = {
|
||||
message: 'Failed to load audio. The format may not be supported.',
|
||||
code: 'MEDIA_ERR',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
};
|
||||
|
||||
// Start loading
|
||||
audio.load();
|
||||
}, [onEnded, onError, state]);
|
||||
|
||||
// Play current audio
|
||||
const play = useCallback(async () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await audioRef.current.play();
|
||||
setState('playing');
|
||||
} catch (err) {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err instanceof Error ? err.name : 'UNKNOWN',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
// Pause current audio
|
||||
const pause = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
setState('paused');
|
||||
}, []);
|
||||
|
||||
// Stop playback completely (requires reload to play again)
|
||||
const stop = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
// Clear URL ref so next load() will actually reload
|
||||
currentUrlRef.current = null;
|
||||
setState('stopped');
|
||||
}, []);
|
||||
|
||||
// Restart playback from the beginning
|
||||
const restart = useCallback(async () => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.currentTime = 0;
|
||||
try {
|
||||
await audioRef.current.play();
|
||||
setState('playing');
|
||||
} catch (err) {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err instanceof Error ? err.name : 'UNKNOWN',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
// Toggle play/pause
|
||||
const toggle = useCallback(async () => {
|
||||
if (state === 'playing') {
|
||||
pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}, [state, play, pause]);
|
||||
|
||||
// Set volume (0-1)
|
||||
const setVolume = useCallback((newVolume: number) => {
|
||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||||
volumeRef.current = clampedVolume;
|
||||
setVolumeState(clampedVolume);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = clampedVolume;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
error,
|
||||
currentUrl,
|
||||
load,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
restart,
|
||||
toggle,
|
||||
isPlaying: state === 'playing',
|
||||
volume,
|
||||
setVolume,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message for playback errors
|
||||
*/
|
||||
function getPlaybackErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotSupportedError') {
|
||||
return 'This audio format is not supported by your browser.';
|
||||
}
|
||||
if (err.name === 'NotAllowedError') {
|
||||
return 'Playback was blocked. Try interacting with the page first.';
|
||||
}
|
||||
return err.message;
|
||||
}
|
||||
return 'An unknown error occurred during playback.';
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* useBlobbiCareActivity - Hook for registering care activity and updating streaks
|
||||
*
|
||||
* This hook provides a centralized way to register care activity for a Blobbi companion.
|
||||
* It handles:
|
||||
* - Calculating streak updates based on the last activity day
|
||||
* - Publishing updated Blobbi state to Nostr
|
||||
* - Updating local cache
|
||||
*
|
||||
* Use this hook whenever care activity should count toward the streak:
|
||||
* - Opening the Blobbi page (page check-in)
|
||||
* - Performing care actions (feed, clean, play, etc.)
|
||||
* - Any other care interaction
|
||||
*
|
||||
* The streak only increments once per calendar day, regardless of how many
|
||||
* activities are performed.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/lib/blobbi';
|
||||
|
||||
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseBlobbiCareActivityParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
export interface CareActivityResult {
|
||||
/** Whether the streak was updated */
|
||||
wasUpdated: boolean;
|
||||
/** The new streak value */
|
||||
newStreak: number;
|
||||
/** Description of what happened */
|
||||
action: StreakUpdateResult['action'];
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to register care activity and update streaks.
|
||||
*
|
||||
* Returns a function to register activity and a mutation for the actual update.
|
||||
* The register function is idempotent - calling it multiple times on the same day
|
||||
* will only update once.
|
||||
*/
|
||||
export function useBlobbiCareActivity({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiCareActivityParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Track if we've already registered activity this session to avoid duplicate calls
|
||||
// This is a performance optimization - the actual idempotency is handled by day comparison
|
||||
const lastRegisteredDay = useRef<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (): Promise<CareActivityResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to register care activity');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion available');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Calculate what the streak update should be
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
// If no update needed (same day), return early without publishing
|
||||
if (!result.wasUpdated) {
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: result.newStreak,
|
||||
action: result.action,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the tag updates
|
||||
const streakUpdates = getStreakTagUpdates(companion, now);
|
||||
|
||||
if (!streakUpdates) {
|
||||
// Shouldn't happen if wasUpdated is true, but handle gracefully
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Build updated tags
|
||||
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
|
||||
|
||||
// Publish the updated event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update local cache
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// Update session tracker
|
||||
lastRegisteredDay.current = result.newLastDay;
|
||||
|
||||
// Log for debugging (dev only)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CareActivity] Streak updated:', {
|
||||
action: result.action,
|
||||
previousStreak: companion.careStreak,
|
||||
newStreak: result.newStreak,
|
||||
lastDay: companion.careStreakLastDay,
|
||||
newDay: result.newLastDay,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: result.newStreak,
|
||||
action: result.action,
|
||||
};
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.wasUpdated) {
|
||||
invalidateCompanion();
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('[CareActivity] Failed to update streak:', error);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Register care activity. Call this when care-related activity happens.
|
||||
* Safe to call multiple times - only updates streak once per day.
|
||||
*
|
||||
* @returns Promise with the result of the activity registration
|
||||
*/
|
||||
const registerCareActivity = useCallback(async (): Promise<CareActivityResult | null> => {
|
||||
if (!companion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Quick check if we've already registered for this companion's last day (session cache)
|
||||
// This is an optimization to avoid unnecessary mutation calls
|
||||
if (lastRegisteredDay.current === companion.careStreakLastDay) {
|
||||
// Already processed this day in this session, skip
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
return mutation.mutateAsync();
|
||||
}, [companion, mutation]);
|
||||
|
||||
return {
|
||||
/** Register care activity - call when page opens or care action happens */
|
||||
registerCareActivity,
|
||||
/** Whether an update is currently in progress */
|
||||
isUpdating: mutation.isPending,
|
||||
/** The last update result */
|
||||
lastResult: mutation.data,
|
||||
/** Any error from the last update attempt */
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
import {
|
||||
clampStat,
|
||||
applyStat,
|
||||
DIRECT_ACTION_METADATA,
|
||||
incrementInteractionTaskTags,
|
||||
type DirectAction,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Configuration for direct action happiness effects.
|
||||
* These are the happiness deltas for each direct action.
|
||||
*/
|
||||
export const DIRECT_ACTION_HAPPINESS_EFFECTS: Record<DirectAction, number> = {
|
||||
play_music: 15,
|
||||
sing: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* Request payload for executing a direct action
|
||||
*/
|
||||
export interface DirectActionRequest {
|
||||
action: DirectAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a direct action
|
||||
*/
|
||||
export interface DirectActionResult {
|
||||
action: DirectAction;
|
||||
happinessChange: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the useBlobbiDirectAction hook
|
||||
*/
|
||||
export interface UseBlobbiDirectActionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called after ensuring companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration happened) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to execute a direct action on a Blobbi companion.
|
||||
* Direct actions (play_music, sing) don't consume inventory items.
|
||||
* They directly affect happiness stat.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion exists
|
||||
* 2. Ensures canonical format before action
|
||||
* 3. Applies accumulated decay
|
||||
* 4. Applies happiness boost
|
||||
* 5. Updates Blobbi state (kind 31124)
|
||||
* 6. Invalidates relevant queries
|
||||
*/
|
||||
export function useBlobbiDirectAction({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiDirectActionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to perform actions');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for action');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Apply Happiness Effect ───
|
||||
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
|
||||
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
|
||||
|
||||
// Build stats update
|
||||
const isEgg = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {
|
||||
happiness: newHappiness.toString(),
|
||||
health: statsAfterDecay.health.toString(),
|
||||
hygiene: statsAfterDecay.hygiene.toString(),
|
||||
};
|
||||
|
||||
if (isEgg) {
|
||||
// Eggs have fixed hunger and energy
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
statsUpdate.hunger = clampStat(statsAfterDecay.hunger).toString();
|
||||
statsUpdate.energy = clampStat(statsAfterDecay.energy).toString();
|
||||
}
|
||||
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter for tasks
|
||||
const companionState = canonical.companion.state;
|
||||
let updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
|
||||
} else if (companionState === 'evolving') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
...statsUpdate,
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const blobbiEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: blobbiTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
happinessChange: happinessDelta,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ action, happinessChange }) => {
|
||||
const actionMeta = DIRECT_ACTION_METADATA[action];
|
||||
toast({
|
||||
title: `${actionMeta.label} complete!`,
|
||||
description: `Your Blobbi's happiness increased by ${happinessChange}!`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
// 'interact' is always tracked, plus the specific action
|
||||
const dailyActions: DailyMissionAction[] = ['interact'];
|
||||
if (action === 'sing') dailyActions.push('sing');
|
||||
if (action === 'play_music') dailyActions.push('play_music');
|
||||
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Action failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiIncubation.ts
|
||||
|
||||
/**
|
||||
* Hooks for Blobbi incubation task system.
|
||||
*
|
||||
* When a user starts incubation:
|
||||
* 1. Apply accumulated decay from last_decay_at to now
|
||||
* 2. Set state to 'incubating'
|
||||
* 3. Add state_started_at timestamp
|
||||
* 4. Update last_decay_at to the same timestamp
|
||||
* 5. Clear any previous task progress
|
||||
*
|
||||
* Tasks are computed from Nostr events with created_at >= state_started_at
|
||||
*/
|
||||
|
||||
import { useMutation } 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';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mode for starting incubation.
|
||||
* This makes the intent explicit rather than auto-detecting behavior.
|
||||
*/
|
||||
export type StartIncubationMode =
|
||||
| 'start' // Normal start (no other Blobbi incubating)
|
||||
| 'restart' // Restart same Blobbi (already incubating)
|
||||
| 'switch'; // Switch from another incubating Blobbi
|
||||
|
||||
/**
|
||||
* Request to start incubation with explicit mode.
|
||||
*/
|
||||
export interface StartIncubationRequest {
|
||||
/** Explicit mode for this operation */
|
||||
mode: StartIncubationMode;
|
||||
/** The d-tag of the other Blobbi to stop (required when mode === 'switch') */
|
||||
stopOtherD?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for start incubation hook.
|
||||
*/
|
||||
export interface UseStartIncubationParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of starting incubation.
|
||||
*/
|
||||
export interface StartIncubationResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Timestamp when incubation started */
|
||||
stateStartedAt: number;
|
||||
/** Mode that was used */
|
||||
mode: StartIncubationMode;
|
||||
/** Name of other Blobbi that was stopped (if mode === 'switch') */
|
||||
stoppedOtherName?: string;
|
||||
}
|
||||
|
||||
// ─── Start Incubation Hook ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to start the incubation process for an egg.
|
||||
*
|
||||
* This sets the Blobbi state to 'incubating' and records the start timestamp.
|
||||
* Tasks will be computed based on events created after this timestamp.
|
||||
*
|
||||
* IMPORTANT: The mode must be explicitly specified by the caller (UI).
|
||||
* This hook does NOT auto-detect whether to switch or restart.
|
||||
* The UI dialog determines the mode and passes it explicitly.
|
||||
*
|
||||
* Modes:
|
||||
* - 'start': Normal start, no other Blobbi incubating
|
||||
* - 'restart': Restart same Blobbi (already incubating), resets task progress
|
||||
* - 'switch': Stop another Blobbi first, then start this one
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in egg stage
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStartIncubation({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (request: StartIncubationRequest): Promise<StartIncubationResult> => {
|
||||
const { mode, stopOtherD } = request;
|
||||
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to start incubation');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'egg') {
|
||||
throw new Error('Only eggs can be incubated');
|
||||
}
|
||||
|
||||
// Validate switch mode requires stopOtherD
|
||||
if (mode === 'switch' && !stopOtherD) {
|
||||
throw new Error('Switch mode requires stopOtherD parameter');
|
||||
}
|
||||
|
||||
let stoppedOtherName: string | undefined;
|
||||
|
||||
// ─── Stop Other Incubating Blobbi (switch mode only) ───
|
||||
if (mode === 'switch' && stopOtherD) {
|
||||
// Fetch the current event for the other Blobbi
|
||||
const [otherEvent] = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [stopOtherD],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
if (otherEvent) {
|
||||
// Get name from the event for the result
|
||||
const nameTag = otherEvent.tags.find(t => t[0] === 'name');
|
||||
stoppedOtherName = nameTag?.[1] ?? stopOtherD;
|
||||
|
||||
// Stop the other Blobbi's incubation
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Parse stats from the event
|
||||
const getTagValue = (tags: string[][], name: string): number =>
|
||||
parseInt(tags.find(t => t[0] === name)?.[1] ?? '50', 10);
|
||||
|
||||
const otherStats = {
|
||||
hunger: getTagValue(otherEvent.tags, 'hunger'),
|
||||
happiness: getTagValue(otherEvent.tags, 'happiness'),
|
||||
health: getTagValue(otherEvent.tags, 'health'),
|
||||
hygiene: getTagValue(otherEvent.tags, 'hygiene'),
|
||||
energy: getTagValue(otherEvent.tags, 'energy'),
|
||||
};
|
||||
const otherLastDecayAt = getTagValue(otherEvent.tags, 'last_decay_at') || now;
|
||||
|
||||
// Apply decay to the other Blobbi
|
||||
const otherDecayResult = applyBlobbiDecay({
|
||||
stage: 'egg',
|
||||
state: 'incubating',
|
||||
stats: otherStats,
|
||||
lastDecayAt: otherLastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Remove task tags and state_started_at from the other Blobbi
|
||||
const otherCleanedTags = otherEvent.tags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
const otherNewTags = updateBlobbiTags(otherCleanedTags, {
|
||||
health: otherDecayResult.stats.health.toString(),
|
||||
hygiene: otherDecayResult.stats.hygiene.toString(),
|
||||
happiness: otherDecayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// Publish the stop event for the other Blobbi
|
||||
const stopEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: otherEvent.content,
|
||||
tags: otherNewTags,
|
||||
});
|
||||
|
||||
// Update the cache for the stopped Blobbi
|
||||
updateCompanionEvent(stopEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for incubation');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
// CRITICAL: Apply decay from last_decay_at to now before changing state
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove any existing task tags when starting fresh (for all modes)
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' && tag[0] !== 'task_completed'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
// Eggs have fixed hunger and energy at 100
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'incubating',
|
||||
state_started_at: nowStr,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
stateStartedAt: now,
|
||||
mode,
|
||||
stoppedOtherName,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name, mode, stoppedOtherName }) => {
|
||||
if (mode === 'switch' && stoppedOtherName) {
|
||||
toast({
|
||||
title: 'Switched incubation!',
|
||||
description: `Stopped ${stoppedOtherName}, now incubating ${name}.`,
|
||||
});
|
||||
} else if (mode === 'restart') {
|
||||
toast({
|
||||
title: 'Incubation restarted!',
|
||||
description: `${name}'s task progress has been reset.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Incubation started!',
|
||||
description: `${name} is now incubating. Complete the tasks to hatch!`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to start incubation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Stop Incubation Hook ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for stop incubation hook.
|
||||
*/
|
||||
export interface UseStopIncubationParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of stopping incubation.
|
||||
*/
|
||||
export interface StopIncubationResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to stop/cancel the incubation process for a Blobbi.
|
||||
*
|
||||
* This resets the Blobbi state to 'active' and clears all task progress tags.
|
||||
* The user can restart incubation later, but will need to complete tasks again.
|
||||
*
|
||||
* When stopping incubation:
|
||||
* - Apply accumulated decay first
|
||||
* - Set state back to 'active'
|
||||
* - Remove state_started_at tag
|
||||
* - Remove all task and task_completed tags
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in incubating state
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStopIncubation({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StopIncubationResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to stop incubation');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.state !== 'incubating') {
|
||||
throw new Error('This Blobbi is not incubating');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove task tags and state_started_at
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
// Eggs have fixed hunger and energy at 100
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Incubation stopped',
|
||||
description: `${name} is no longer incubating. Task progress has been reset.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to stop incubation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Start Evolution Hook ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for start evolution hook.
|
||||
*/
|
||||
export interface UseStartEvolutionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of starting evolution.
|
||||
*/
|
||||
export interface StartEvolutionResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Timestamp when evolution started */
|
||||
stateStartedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to start the evolution process for a baby Blobbi.
|
||||
*
|
||||
* This sets the Blobbi state to 'evolving' and records the start timestamp.
|
||||
* Tasks will be computed based on events created after this timestamp.
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in baby stage
|
||||
* - Blobbi must not already be evolving
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStartEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StartEvolutionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to start evolution');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'baby') {
|
||||
throw new Error('Only baby Blobbis can evolve');
|
||||
}
|
||||
|
||||
if (companion.state === 'evolving') {
|
||||
throw new Error('This Blobbi is already evolving');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for evolution');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove any existing task tags when starting fresh
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' && tag[0] !== 'task_completed'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'evolving',
|
||||
state_started_at: nowStr,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
stateStartedAt: now,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution started!',
|
||||
description: `${name} is now working towards evolution. Complete the tasks to evolve!`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to start evolution',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Stop Evolution Hook ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for stop evolution hook.
|
||||
*/
|
||||
export interface UseStopEvolutionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of stopping evolution.
|
||||
*/
|
||||
export interface StopEvolutionResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to stop/cancel the evolution process for a Blobbi.
|
||||
*
|
||||
* This resets the Blobbi state to 'active' and clears all task progress tags.
|
||||
* The user can restart evolution later, but will need to complete tasks again.
|
||||
*
|
||||
* When stopping evolution:
|
||||
* - Apply accumulated decay first
|
||||
* - Set state back to 'active'
|
||||
* - Remove state_started_at tag
|
||||
* - Remove all task and task_completed tags
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in evolving state
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStopEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StopEvolutionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to stop evolution');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.state !== 'evolving') {
|
||||
throw new Error('This Blobbi is not evolving');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove task tags and state_started_at
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution stopped',
|
||||
description: `${name} is no longer evolving. Task progress has been reset.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to stop evolution',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sync Task Completions Hook ───────────────────────────────────────────────
|
||||
|
||||
/** Enable debug logging in development only */
|
||||
const DEBUG_TASK_SYNC = import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Parameters for syncing task completions (works for both hatch and evolve).
|
||||
*/
|
||||
export interface UseSyncTaskCompletionsParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task completions to sync (from useHatchTasks or useEvolveTasks).
|
||||
*/
|
||||
export interface TaskCompletionToSync {
|
||||
taskId: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of sync operation.
|
||||
*/
|
||||
export interface SyncTaskCompletionsResult {
|
||||
/** Task IDs that were synced (empty if nothing needed) */
|
||||
synced: string[];
|
||||
/** Whether sync was skipped (no diff) */
|
||||
skipped: boolean;
|
||||
/** Reason for skip (for debugging) */
|
||||
skipReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to sync persistent task completions to kind 31124 tags.
|
||||
* Works for both hatch (incubating) and evolve (evolving) processes.
|
||||
*
|
||||
* CRITICAL: This is a cache-only sync. It must be:
|
||||
* 1. Fully idempotent - calling multiple times with same data = no-op
|
||||
* 2. Diff-based - only publish when tags would actually change
|
||||
* 3. Safe - no last_interaction update (this is cache sync, not user action)
|
||||
* 4. Only sync PERSISTENT tasks - dynamic tasks must NEVER be synced
|
||||
*
|
||||
* Source of truth = computed task state from Nostr events.
|
||||
* Tags = cache layer for faster access.
|
||||
*/
|
||||
export function useSyncTaskCompletions({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseSyncTaskCompletionsParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tasksToSync: TaskCompletionToSync[]): Promise<SyncTaskCompletionsResult> => {
|
||||
// ─── Early Guards ───
|
||||
if (!user?.pubkey) {
|
||||
return { synced: [], skipped: true, skipReason: 'no_user' };
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
return { synced: [], skipped: true, skipReason: 'no_companion' };
|
||||
}
|
||||
|
||||
// Must be in an active task process (incubating or evolving)
|
||||
if (companion.state !== 'incubating' && companion.state !== 'evolving') {
|
||||
return { synced: [], skipped: true, skipReason: 'not_in_task_process' };
|
||||
}
|
||||
|
||||
// ─── Compute Diff ───
|
||||
// Get cached completions from companion.tasksCompleted (parsed from tags)
|
||||
const cachedCompletions = new Set(companion.tasksCompleted);
|
||||
|
||||
// Get computed completions from tasks (works for both hatch and evolve)
|
||||
const computedCompletions = tasksToSync
|
||||
.filter(t => t.completed)
|
||||
.map(t => t.taskId);
|
||||
|
||||
// Find tasks that are computed as complete but NOT in cache
|
||||
const missingFromCache = computedCompletions.filter(id => !cachedCompletions.has(id));
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Diff check:', {
|
||||
cachedCompletions: Array.from(cachedCompletions),
|
||||
computedCompletions,
|
||||
missingFromCache,
|
||||
});
|
||||
}
|
||||
|
||||
// If no diff, skip entirely
|
||||
if (missingFromCache.length === 0) {
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Skipped: no diff between computed and cached');
|
||||
}
|
||||
return { synced: [], skipped: true, skipReason: 'no_diff' };
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
return { synced: [], skipped: true, skipReason: 'canonical_failed' };
|
||||
}
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Re-check against canonical.allTags (may have updated since companion was parsed)
|
||||
const existingCompletionTags = new Set(
|
||||
canonical.allTags
|
||||
.filter(tag => tag[0] === 'task_completed')
|
||||
.map(tag => tag[1])
|
||||
);
|
||||
|
||||
// Filter to only truly missing tags
|
||||
const tagsToAdd = missingFromCache.filter(id => !existingCompletionTags.has(id));
|
||||
|
||||
if (tagsToAdd.length === 0) {
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Skipped: all tags already exist in canonical');
|
||||
}
|
||||
return { synced: [], skipped: true, skipReason: 'tags_already_exist' };
|
||||
}
|
||||
|
||||
// Add only the missing task_completed tags
|
||||
// CRITICAL: Do NOT update last_interaction - this is cache sync, not user action
|
||||
const updatedTags = [
|
||||
...canonical.allTags,
|
||||
...tagsToAdd.map(id => ['task_completed', id]),
|
||||
];
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Publishing:', {
|
||||
tagsToAdd,
|
||||
totalTags: updatedTags.length,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Publish ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Published successfully:', tagsToAdd);
|
||||
}
|
||||
|
||||
return { synced: tagsToAdd, skipped: false };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiStageTransition.ts
|
||||
|
||||
/**
|
||||
* Hooks for Blobbi stage transitions (hatch, evolve).
|
||||
*
|
||||
* Both transitions follow the same decay pattern:
|
||||
* 1. Apply accumulated decay from `last_decay_at` to `now`
|
||||
* 2. Use decayed stats as the source of truth for the transition
|
||||
* 3. Publish new event with decayed stats + new stage
|
||||
* 4. Reset `last_decay_at` to current timestamp
|
||||
*
|
||||
* @see docs/blobbi/decay-system.md
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
DEFAULT_EGG_STATS,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
import { validateAndRepairBlobbiTags } from '@/lib/blobbi-tag-schema';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
|
||||
// ─── Content Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate the content string for a Blobbi at a given stage.
|
||||
* Format: "{name} is a {stage} Blobbi."
|
||||
*
|
||||
* Uses correct grammar: "an egg" vs "a baby/adult"
|
||||
*/
|
||||
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
const article = stage === 'egg' ? 'an' : 'a';
|
||||
return `${name} is ${article} ${stage} Blobbi.`;
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of ensuring canonical companion before action.
|
||||
* This is the same interface used by useBlobbiUseInventoryItem.
|
||||
*/
|
||||
export interface CanonicalActionResult {
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration */
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for stage transition hooks.
|
||||
*/
|
||||
export interface UseBlobbiStageTransitionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a stage transition.
|
||||
*/
|
||||
export interface StageTransitionResult {
|
||||
/** Previous stage before transition */
|
||||
previousStage: BlobbiStage;
|
||||
/** New stage after transition */
|
||||
newStage: BlobbiStage;
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Stats after decay was applied (before any transition bonuses) */
|
||||
decayedStats: {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
health: number;
|
||||
hygiene: number;
|
||||
energy: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hatch Hook ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to hatch an egg into a baby Blobbi.
|
||||
*
|
||||
* Transition: egg -> baby
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in egg stage
|
||||
* - Applies accumulated decay before transition
|
||||
* - Resets stats to healthy baby defaults (inherits health from egg)
|
||||
* - Sets last_decay_at to current timestamp
|
||||
*/
|
||||
export function useBlobbiHatch({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StageTransitionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to hatch');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'egg') {
|
||||
throw new Error('Only eggs can be hatched');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for hatching');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any stage transition.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Calculate Baby Stats ───
|
||||
// Baby inherits the decayed health from the egg
|
||||
// Other stats start fresh at 100 for the new life stage
|
||||
const babyStats = {
|
||||
hunger: DEFAULT_EGG_STATS.hunger, // Start full
|
||||
happiness: DEFAULT_EGG_STATS.happiness, // Start happy
|
||||
health: decayResult.stats.health, // Inherit from egg
|
||||
hygiene: DEFAULT_EGG_STATS.hygiene, // Start clean
|
||||
energy: DEFAULT_EGG_STATS.energy, // Start energized
|
||||
};
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
|
||||
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Build the updated tags using the central merge function
|
||||
// Get streak updates (hatching counts as care activity!)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
const mergedTags = updateBlobbiTags(canonical.allTags, {
|
||||
stage: 'baby',
|
||||
state: 'active', // Newly hatched babies are awake
|
||||
hunger: babyStats.hunger.toString(),
|
||||
happiness: babyStats.happiness.toString(),
|
||||
health: babyStats.health.toString(),
|
||||
hygiene: babyStats.hygiene.toString(),
|
||||
energy: babyStats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Validate and Repair Tags ───
|
||||
// Use the tag integrity guard to ensure all persistent tags are preserved
|
||||
// and task-related tags are properly cleaned up for stage transitions
|
||||
const repairResult = validateAndRepairBlobbiTags(
|
||||
mergedTags,
|
||||
canonical.allTags,
|
||||
{ cleanupTaskTags: true }
|
||||
);
|
||||
|
||||
if (repairResult.errors.length > 0) {
|
||||
console.error('[Hatch] Tag validation errors:', repairResult.errors);
|
||||
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
if (repairResult.repaired && import.meta.env.DEV) {
|
||||
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
|
||||
}
|
||||
|
||||
const newTags = repairResult.tags;
|
||||
|
||||
// ─── Generate New Content for Baby Stage ───
|
||||
// CRITICAL: Content must reflect the new stage
|
||||
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: newContent,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'egg',
|
||||
newStage: 'baby',
|
||||
name: canonical.companion.name,
|
||||
decayedStats: decayResult.stats,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Your egg hatched!',
|
||||
description: `${name} is now a baby Blobbi! Take good care of them.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to hatch',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Evolve Hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to evolve a baby Blobbi into an adult.
|
||||
*
|
||||
* Transition: baby -> adult
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in baby stage
|
||||
* - Applies accumulated decay before transition
|
||||
* - Preserves all stats (decay already applied)
|
||||
* - Sets last_decay_at to current timestamp
|
||||
*/
|
||||
export function useBlobbiEvolve({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StageTransitionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to evolve');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'baby') {
|
||||
if (companion.stage === 'egg') {
|
||||
throw new Error('Eggs must hatch before they can evolve');
|
||||
}
|
||||
if (companion.stage === 'adult') {
|
||||
throw new Error('This Blobbi is already fully evolved');
|
||||
}
|
||||
throw new Error('Only baby Blobbis can evolve');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for evolution');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any stage transition.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Adult Stats ───
|
||||
// Adult inherits all decayed stats from baby
|
||||
// No stat reset - evolution preserves current condition
|
||||
const adultStats = decayResult.stats;
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
|
||||
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Get streak updates (evolving counts as care activity!)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// Build the updated tags using the central merge function
|
||||
const mergedTags = updateBlobbiTags(canonical.allTags, {
|
||||
stage: 'adult',
|
||||
state: 'active', // Evolution completes with active state
|
||||
hunger: adultStats.hunger.toString(),
|
||||
happiness: adultStats.happiness.toString(),
|
||||
health: adultStats.health.toString(),
|
||||
hygiene: adultStats.hygiene.toString(),
|
||||
energy: adultStats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Validate and Repair Tags ───
|
||||
// Use the tag integrity guard to ensure all persistent tags are preserved
|
||||
// and task-related tags are properly cleaned up for stage transitions
|
||||
const repairResult = validateAndRepairBlobbiTags(
|
||||
mergedTags,
|
||||
canonical.allTags,
|
||||
{ cleanupTaskTags: true }
|
||||
);
|
||||
|
||||
if (repairResult.errors.length > 0) {
|
||||
console.error('[Evolve] Tag validation errors:', repairResult.errors);
|
||||
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
if (repairResult.repaired && import.meta.env.DEV) {
|
||||
console.log('[Evolve] Tag repairs applied:', repairResult.repairs);
|
||||
}
|
||||
|
||||
const newTags = repairResult.tags;
|
||||
|
||||
// ─── Generate New Content for Adult Stage ───
|
||||
// CRITICAL: Content must reflect the new stage
|
||||
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: newContent,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'baby',
|
||||
newStage: 'adult',
|
||||
name: canonical.companion.name,
|
||||
decayedStats: decayResult.stats,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution complete!',
|
||||
description: `${name} has evolved into an adult Blobbi!`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to evolve',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
clampStat,
|
||||
applyStat,
|
||||
hasMedicineEffectForEgg,
|
||||
hasHygieneEffectForEgg,
|
||||
incrementInteractionTaskTags,
|
||||
type InventoryAction,
|
||||
ACTION_METADATA,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
/**
|
||||
* Request payload for using an inventory item
|
||||
*/
|
||||
export interface UseItemRequest {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
/** Number of items to use (defaults to 1) */
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of using an inventory item
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
itemName: string;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
statsChanged: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the useBlobbiUseInventoryItem hook
|
||||
*/
|
||||
export interface UseBlobbiUseInventoryItemParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called after ensuring companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration (use instead of profile.allTags) */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration (use instead of profile.storage) */
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Update profile event in local cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Hook to use an inventory item on a Blobbi companion.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
export function useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to use items');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
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);
|
||||
throw new Error(message ?? 'This companion cannot use this item');
|
||||
}
|
||||
|
||||
// Validate item exists in shop catalog
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) {
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// For eggs, validate that items have applicable effects
|
||||
const isEgg = companion.stage === 'egg';
|
||||
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
throw new Error('This medicine has no effect on eggs');
|
||||
}
|
||||
if (isEgg && action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect)) {
|
||||
throw new Error('This item has no cleaning effect on eggs');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for action');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any user interaction updates stats.
|
||||
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Start with decayed stats as the base
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── 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.
|
||||
// Use canonical companion stage for egg checks
|
||||
const isEggCompanion = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
|
||||
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
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHealth = applyStat(currentHealth, 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
|
||||
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);
|
||||
}
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
|
||||
statsUpdate.happiness = currentHappiness.toString();
|
||||
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
|
||||
if (totalHappinessChange !== 0) {
|
||||
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
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentStats = applyItemEffects(currentStats, shopItem.effect);
|
||||
}
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
|
||||
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
|
||||
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
|
||||
|
||||
statsUpdate.energy = clampStat(currentStats.energy).toString();
|
||||
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
|
||||
|
||||
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
|
||||
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
|
||||
|
||||
statsUpdate.health = clampStat(currentStats.health).toString();
|
||||
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
|
||||
}
|
||||
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter for tasks
|
||||
const companionState = canonical.companion.state;
|
||||
let updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
|
||||
} else if (companionState === 'evolving') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
...statsUpdate,
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const blobbiEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: blobbiTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
|
||||
// instead of profile.storage/profile.allTags to avoid restoring
|
||||
// stale/legacy values after migration
|
||||
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);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
invalidateProfile();
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
action,
|
||||
quantity,
|
||||
statsChanged,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ itemName, action, quantity }) => {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${itemName}${quantityText} on your Blobbi.`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
|
||||
const dailyActions: DailyMissionAction[] = ['interact'];
|
||||
if (action === 'feed') dailyActions.push('feed');
|
||||
if (action === 'clean') dailyActions.push('clean');
|
||||
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to use item',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* useClaimMissionReward - Hook for claiming daily mission rewards
|
||||
*
|
||||
* Handles:
|
||||
* - Persisting coin rewards to kind 11125 Blobbonaut profile
|
||||
* - Updating localStorage mission state
|
||||
* - Idempotent claiming (prevents double-credit)
|
||||
* - Optimistic cache updates
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbonautProfile } from '@/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
} from '@/lib/blobbi';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ClaimMissionRequest {
|
||||
missionId: string;
|
||||
}
|
||||
|
||||
/** Special ID for claiming the bonus mission */
|
||||
export const BONUS_MISSION_ID = 'bonus_daily_complete';
|
||||
|
||||
export interface ClaimMissionResult {
|
||||
missionId: string;
|
||||
coinsEarned: number;
|
||||
newTotalCoins: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useClaimMissionReward] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to claim daily mission rewards.
|
||||
*
|
||||
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
|
||||
* ensuring rewards are stored on-chain rather than just in localStorage.
|
||||
*
|
||||
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
|
||||
* @param updateProfileEvent - Callback to update the profile in the query cache
|
||||
*/
|
||||
export function useClaimMissionReward(
|
||||
currentProfile: BlobbonautProfile | null,
|
||||
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
|
||||
) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missionId }: ClaimMissionRequest): Promise<ClaimMissionResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to claim rewards');
|
||||
}
|
||||
|
||||
if (!currentProfile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
|
||||
}
|
||||
|
||||
// Handle bonus mission claim
|
||||
if (missionId === BONUS_MISSION_ID) {
|
||||
// Check if bonus is available
|
||||
if (!isBonusMissionAvailable(missionsState!)) {
|
||||
throw new Error('Bonus mission not available yet');
|
||||
}
|
||||
|
||||
// Check if already claimed
|
||||
if (isBonusMissionClaimed(missionsState!)) {
|
||||
throw new Error('Bonus reward already claimed');
|
||||
}
|
||||
|
||||
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
});
|
||||
|
||||
// Publish updated profile event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update the query cache
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Update localStorage to mark bonus as claimed
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
};
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true, isBonus: true }
|
||||
}));
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle regular mission claim
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (!mission) {
|
||||
throw new Error('Mission not found');
|
||||
}
|
||||
|
||||
// Check if already claimed (idempotency check)
|
||||
if (mission.claimed) {
|
||||
throw new Error('Reward already claimed');
|
||||
}
|
||||
|
||||
// Check if mission is completed
|
||||
if (!mission.completed) {
|
||||
throw new Error('Mission not completed yet');
|
||||
}
|
||||
|
||||
const coinsToAdd = mission.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
});
|
||||
|
||||
// Publish updated profile event to kind 11125
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update the query cache optimistically
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Now update localStorage to mark mission as claimed
|
||||
const updatedMissions = missionsState!.missions.map(m =>
|
||||
m.id === missionId ? { ...m, claimed: true } : m
|
||||
);
|
||||
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
};
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true }
|
||||
}));
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ coinsEarned }) => {
|
||||
// Invalidate profile query to ensure fresh data
|
||||
if (user?.pubkey) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: 'Reward Claimed!',
|
||||
description: `You earned ${coinsEarned} coins.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
// Don't show error for already claimed (user might have double-clicked)
|
||||
if (error.message === 'Reward already claimed' || error.message === 'Bonus reward already claimed') {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to Claim Reward',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* useDailyMissions - Hook for managing Blobbi daily missions
|
||||
*
|
||||
* Provides:
|
||||
* - Daily mission state management with localStorage persistence
|
||||
* - Automatic daily reset
|
||||
* - Progress tracking functions
|
||||
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
|
||||
* - Stage-based filtering (only shows missions user can complete)
|
||||
* - Bonus mission tracking
|
||||
*
|
||||
* Note: Reward claiming should be done via useClaimMissionReward hook,
|
||||
* which persists coins to the kind 11125 Blobbonaut profile.
|
||||
*/
|
||||
|
||||
import { useMemo, useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
areAllMissionsCompleted,
|
||||
areAllMissionsClaimed,
|
||||
getTotalPotentialReward,
|
||||
getTodayClaimedReward,
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
getRerollsRemaining,
|
||||
MAX_DAILY_REROLLS,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseDailyMissionsOptions {
|
||||
/** Available Blobbi stages the user has (filters eligible missions) */
|
||||
availableStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
export interface UseDailyMissionsResult {
|
||||
/** Current daily missions state */
|
||||
missions: DailyMission[];
|
||||
/** Whether all missions are completed */
|
||||
allCompleted: boolean;
|
||||
/** Whether all missions are claimed */
|
||||
allClaimed: boolean;
|
||||
/** Total potential reward for today (including bonus if available) */
|
||||
totalPotentialReward: number;
|
||||
/** Total claimed reward for today */
|
||||
todayClaimedReward: number;
|
||||
/** Lifetime total coins earned from daily missions */
|
||||
lifetimeCoinsEarned: number;
|
||||
/** Whether the bonus mission is available (all regular missions completed) */
|
||||
bonusAvailable: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
bonusClaimed: boolean;
|
||||
/** Bonus mission reward amount */
|
||||
bonusReward: number;
|
||||
/** Whether user has no eligible missions (e.g., only eggs) */
|
||||
noMissionsAvailable: boolean;
|
||||
/** Number of rerolls remaining for today */
|
||||
rerollsRemaining: number;
|
||||
/** Maximum rerolls allowed per day */
|
||||
maxRerolls: number;
|
||||
/** Force refresh missions (for testing or manual reset) */
|
||||
forceReset: () => void;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useDailyMissions] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
|
||||
const { availableStages } = options;
|
||||
const { user } = useCurrentUser();
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// Read state directly from localStorage, with a version counter to trigger re-reads
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Read from localStorage on every render when version changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
|
||||
const state = useMemo(() => readMissionsState(), [version]);
|
||||
|
||||
// Wrapper to write state and update version
|
||||
const setState = useCallback((newState: DailyMissionsState) => {
|
||||
writeMissionsState(newState);
|
||||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
// Listen for external updates from mutations (reroll, claim, progress tracking)
|
||||
// This re-reads localStorage when other hooks modify it directly
|
||||
useEffect(() => {
|
||||
const handleExternalUpdate = () => {
|
||||
// Bump version to trigger a re-read from localStorage
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
window.addEventListener('daily-missions-updated', handleExternalUpdate);
|
||||
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
|
||||
}, []);
|
||||
|
||||
// Stable key for availableStages to use in dependencies
|
||||
const stagesKey = availableStages?.sort().join(',') ?? '';
|
||||
|
||||
// Ensure we have valid state for today
|
||||
const currentState = useMemo(() => {
|
||||
// Check if we need to reset for a new day
|
||||
if (needsDailyReset(state)) {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
// Persist the reset state (this will trigger version bump via setState)
|
||||
writeMissionsState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state && state.rerollsRemaining === undefined) {
|
||||
const migratedState = {
|
||||
...state,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
writeMissionsState(migratedState);
|
||||
return migratedState;
|
||||
}
|
||||
|
||||
return state!;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, pubkey, stagesKey]);
|
||||
|
||||
// Force reset missions (for testing)
|
||||
const forceReset = () => {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
setState(newState);
|
||||
};
|
||||
|
||||
// Computed values
|
||||
const missions = currentState.missions;
|
||||
const allCompleted = areAllMissionsCompleted(currentState);
|
||||
const allClaimed = areAllMissionsClaimed(currentState);
|
||||
const bonusAvailable = isBonusMissionAvailable(currentState);
|
||||
const bonusClaimed = isBonusMissionClaimed(currentState);
|
||||
const bonusReward = BONUS_MISSION_DEFINITION.reward;
|
||||
const noMissionsAvailable = missions.length === 0;
|
||||
const rerollsRemaining = getRerollsRemaining(currentState);
|
||||
const maxRerolls = MAX_DAILY_REROLLS;
|
||||
|
||||
// Total potential includes bonus if regular missions exist
|
||||
const basePotentialReward = getTotalPotentialReward(currentState);
|
||||
const totalPotentialReward = missions.length > 0
|
||||
? basePotentialReward + bonusReward
|
||||
: 0;
|
||||
|
||||
// Today's claimed includes bonus if claimed
|
||||
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
|
||||
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
|
||||
|
||||
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
|
||||
|
||||
return {
|
||||
missions,
|
||||
allCompleted,
|
||||
allClaimed,
|
||||
totalPotentialReward,
|
||||
todayClaimedReward,
|
||||
lifetimeCoinsEarned,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
maxRerolls,
|
||||
forceReset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
// src/blobbi/actions/hooks/useEvolveTasks.ts
|
||||
|
||||
/**
|
||||
* Hook to compute evolve task progress from Nostr events and current stats.
|
||||
*
|
||||
* CRITICAL ARCHITECTURE:
|
||||
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
|
||||
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
|
||||
*
|
||||
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
|
||||
import {
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
KIND_PROFILE_METADATA,
|
||||
KIND_SHORT_TEXT_NOTE,
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
sanitizeToHashtag,
|
||||
type HatchTask,
|
||||
type TaskType,
|
||||
} from './useHatchTasks';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for wall edit events */
|
||||
export const KIND_WALL_EDIT = 16769;
|
||||
|
||||
/** Required themes for evolve task */
|
||||
export const EVOLVE_REQUIRED_THEMES = 3;
|
||||
|
||||
/** Required color moments for evolve task */
|
||||
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
|
||||
|
||||
/** Required posts for evolve task (lighter than hatch - just 1 evolve-specific post) */
|
||||
export const EVOLVE_REQUIRED_POSTS = 1;
|
||||
|
||||
/** Required interactions for evolve task */
|
||||
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
|
||||
|
||||
/** Prefix text for Blobbi evolve post */
|
||||
export const BLOBBI_EVOLVE_POST_PREFIX = 'Hello Nostr! Posting to evolve';
|
||||
|
||||
/** Stat threshold for evolve dynamic task (all stats >= 80) */
|
||||
export const EVOLVE_STAT_THRESHOLD = 80;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Re-export task types for convenience
|
||||
export type { HatchTask as EvolveTask, TaskType };
|
||||
|
||||
/**
|
||||
* Result of computing evolve tasks.
|
||||
*/
|
||||
export interface EvolveTasksResult {
|
||||
tasks: HatchTask[];
|
||||
/** All persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Dynamic stat task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
/** All tasks (persistent + dynamic) are complete - required to evolve */
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Refetch task progress */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi evolve post.
|
||||
* Must contain the evolve prefix and all required hashtags including the Blobbi name.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
|
||||
*/
|
||||
export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolean {
|
||||
// Check content starts with evolve prefix
|
||||
if (!event.content.startsWith(BLOBBI_EVOLVE_POST_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required hashtags in tags
|
||||
const hashtags = event.tags
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
);
|
||||
|
||||
if (!hasRequiredHashtags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blobbi name hashtag must also be present
|
||||
const blobbiHashtag = sanitizeToHashtag(blobbiName);
|
||||
return hashtags.includes(blobbiHashtag);
|
||||
}
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to compute evolve task progress from Nostr events and current stats.
|
||||
*
|
||||
* PERSISTENT TASKS (event-based, can be cached):
|
||||
* 1. Create 3 Themes (kind 36767)
|
||||
* 2. Create 3 Color Moments (kind 3367)
|
||||
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
|
||||
* 4. Interact 21 times (tracked via companion.tasks cache)
|
||||
* 5. Edit Wall once (kind 16769)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 6. Maintain All Stats >= 80
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be in evolving state)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
*/
|
||||
export function useEvolveTasks(
|
||||
companion: BlobbiCompanion | null,
|
||||
interactionCount?: number
|
||||
): EvolveTasksResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const stateStartedAt = companion?.stateStartedAt;
|
||||
const isEvolving = companion?.state === 'evolving';
|
||||
|
||||
// Query for all relevant events
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['evolve-tasks', pubkey, stateStartedAt],
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !stateStartedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build filters for events we need
|
||||
const filters: NostrFilter[] = [
|
||||
// Theme definitions after start
|
||||
{
|
||||
kinds: [KIND_THEME_DEFINITION],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Color moments after start
|
||||
{
|
||||
kinds: [KIND_COLOR_MOMENT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Posts after start (will filter for valid evolve posts)
|
||||
{
|
||||
kinds: [KIND_SHORT_TEXT_NOTE],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Only need 1 valid evolve post
|
||||
},
|
||||
// Wall edits after start
|
||||
{
|
||||
kinds: [KIND_WALL_EDIT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need 1
|
||||
},
|
||||
// Profile metadata after start (for Blobbi shape check)
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Execute all queries
|
||||
const events = await nostr.query(filters);
|
||||
|
||||
// Categorize events
|
||||
const themeEvents = events.filter(e =>
|
||||
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const colorMomentEvents = events.filter(e =>
|
||||
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const postEvents = events.filter(e =>
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const wallEditEvents = events.filter(e =>
|
||||
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Get latest profile after start
|
||||
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
|
||||
const profileAfter = profileEvents
|
||||
.filter(e => e.created_at >= stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
return {
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
wallEditEvents,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey && !!stateStartedAt && isEvolving,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchInterval: 60_000, // Refetch every minute
|
||||
});
|
||||
|
||||
// ─── Compute PERSISTENT Tasks ───
|
||||
const tasks: HatchTask[] = [];
|
||||
|
||||
// 1. Create 3 Themes (PERSISTENT)
|
||||
const themeCount = data?.themeEvents?.length ?? 0;
|
||||
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
|
||||
tasks.push({
|
||||
id: 'create_themes',
|
||||
name: 'Create Themes',
|
||||
description: `Create ${EVOLVE_REQUIRED_THEMES} custom themes`,
|
||||
current: Math.min(themeCount, EVOLVE_REQUIRED_THEMES),
|
||||
required: EVOLVE_REQUIRED_THEMES,
|
||||
completed: themesCompleted,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/themes',
|
||||
actionLabel: 'Create Theme',
|
||||
});
|
||||
|
||||
// 2. Create 3 Color Moments (PERSISTENT)
|
||||
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
|
||||
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
|
||||
tasks.push({
|
||||
id: 'color_moments',
|
||||
name: 'Color Moments',
|
||||
description: `Share ${EVOLVE_REQUIRED_COLOR_MOMENTS} color moments on espy`,
|
||||
current: Math.min(colorMomentCount, EVOLVE_REQUIRED_COLOR_MOMENTS),
|
||||
required: EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
completed: colorMomentsCompleted,
|
||||
type: 'persistent',
|
||||
action: 'external_link',
|
||||
actionTarget: 'https://espy.you/',
|
||||
actionLabel: 'Open espy',
|
||||
});
|
||||
|
||||
// 3. Create 1 Evolve Post (PERSISTENT) - lighter than hatch
|
||||
const blobbiName = companion?.name ?? '';
|
||||
const validPosts = data?.postEvents?.filter(e => isValidEvolvePost(e, blobbiName)) ?? [];
|
||||
const postCount = validPosts.length;
|
||||
const postsCompleted = postCount >= EVOLVE_REQUIRED_POSTS;
|
||||
tasks.push({
|
||||
id: 'create_posts',
|
||||
name: 'Share Evolution',
|
||||
description: 'Post about your Blobbi evolving',
|
||||
current: Math.min(postCount, EVOLVE_REQUIRED_POSTS),
|
||||
required: EVOLVE_REQUIRED_POSTS,
|
||||
completed: postsCompleted,
|
||||
type: 'persistent',
|
||||
action: 'open_modal',
|
||||
actionTarget: 'blobbi_post',
|
||||
actionLabel: 'Create Post',
|
||||
});
|
||||
|
||||
// 4. Interact 21 times (PERSISTENT)
|
||||
const interactions = interactionCount ?? 0;
|
||||
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
|
||||
tasks.push({
|
||||
id: 'interactions',
|
||||
name: 'Interact with Blobbi',
|
||||
description: `Care for your Blobbi ${EVOLVE_REQUIRED_INTERACTIONS} times`,
|
||||
current: Math.min(interactions, EVOLVE_REQUIRED_INTERACTIONS),
|
||||
required: EVOLVE_REQUIRED_INTERACTIONS,
|
||||
completed: interactionsCompleted,
|
||||
type: 'persistent',
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// 5. Edit Wall once (PERSISTENT)
|
||||
const wallEditCount = data?.wallEditEvents?.length ?? 0;
|
||||
const hasWallEdit = wallEditCount >= 1;
|
||||
tasks.push({
|
||||
id: 'edit_wall',
|
||||
name: 'Edit Your Wall',
|
||||
description: 'Customize your profile wall',
|
||||
current: hasWallEdit ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasWallEdit,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/settings/profile',
|
||||
actionLabel: 'Edit Wall',
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
// 7. Maintain All Stats >= 80
|
||||
const stats = companion?.stats ?? {};
|
||||
const hunger = stats.hunger ?? 0;
|
||||
const happiness = stats.happiness ?? 0;
|
||||
const health = stats.health ?? 0;
|
||||
const hygiene = stats.hygiene ?? 0;
|
||||
const energy = stats.energy ?? 0;
|
||||
|
||||
const statsOk =
|
||||
hunger >= EVOLVE_STAT_THRESHOLD &&
|
||||
happiness >= EVOLVE_STAT_THRESHOLD &&
|
||||
health >= EVOLVE_STAT_THRESHOLD &&
|
||||
hygiene >= EVOLVE_STAT_THRESHOLD &&
|
||||
energy >= EVOLVE_STAT_THRESHOLD;
|
||||
|
||||
// Calculate minimum stat for progress display
|
||||
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
|
||||
|
||||
tasks.push({
|
||||
id: 'maintain_stats',
|
||||
name: 'Peak Condition',
|
||||
description: `Keep all stats above ${EVOLVE_STAT_THRESHOLD}`,
|
||||
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
|
||||
required: EVOLVE_STAT_THRESHOLD,
|
||||
completed: statsOk,
|
||||
type: 'dynamic', // CRITICAL: Never persist this task
|
||||
// No action - just care for your Blobbi
|
||||
});
|
||||
|
||||
// ─── Compute Completion States ───
|
||||
const persistentTasks = tasks.filter(t => t.type === 'persistent');
|
||||
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
|
||||
|
||||
const persistentTasksComplete = persistentTasks.every(t => t.completed);
|
||||
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
|
||||
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
|
||||
|
||||
return {
|
||||
tasks,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current interaction count for evolve from companion task cache.
|
||||
*/
|
||||
export function getEvolveInteractionCount(companion: BlobbiCompanion | null): number {
|
||||
if (!companion) return 0;
|
||||
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
|
||||
return interactionTask?.value ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// src/blobbi/actions/hooks/useHatchTasks.ts
|
||||
|
||||
/**
|
||||
* Hook to compute hatch task progress from Nostr events.
|
||||
*
|
||||
* CRITICAL ARCHITECTURE:
|
||||
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
|
||||
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
|
||||
*
|
||||
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
|
||||
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for theme definition events */
|
||||
export const KIND_THEME_DEFINITION = 36767;
|
||||
/** Kind for color moment events (espy.you) */
|
||||
export const KIND_COLOR_MOMENT = 3367;
|
||||
/** Kind for profile metadata */
|
||||
export const KIND_PROFILE_METADATA = 0;
|
||||
/** Kind for short text notes */
|
||||
export const KIND_SHORT_TEXT_NOTE = 1;
|
||||
|
||||
/** Required interactions to complete the hatch interactions task */
|
||||
export const HATCH_REQUIRED_INTERACTIONS = 7;
|
||||
|
||||
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
|
||||
|
||||
/** Prefix text for Blobbi hatch post */
|
||||
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
|
||||
|
||||
/** Stat threshold for hatch dynamic task (health, hygiene, happiness >= 70) */
|
||||
export const HATCH_STAT_THRESHOLD = 70;
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
|
||||
|
||||
/**
|
||||
* Sanitize a name into a valid hashtag format.
|
||||
* Must match the implementation in BlobbiPostModal.tsx.
|
||||
*/
|
||||
export function sanitizeToHashtag(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
// Remove emojis and special characters, keep letters, numbers, underscores
|
||||
.replace(/[^\p{L}\p{N}_]/gu, '')
|
||||
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
|
||||
.replace(/^(\d)/, 'blobbi$1')
|
||||
// Limit length
|
||||
.slice(0, 30)
|
||||
// Fallback if empty
|
||||
|| 'myblobbi';
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Task type classification.
|
||||
* - persistent: Based on Nostr events, can be cached in tags
|
||||
* - dynamic: Based on current stats, NEVER stored in tags
|
||||
*/
|
||||
export type TaskType = 'persistent' | 'dynamic';
|
||||
|
||||
/**
|
||||
* Individual task definition.
|
||||
*/
|
||||
export interface HatchTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Current progress value */
|
||||
current: number;
|
||||
/** Required value for completion */
|
||||
required: number;
|
||||
/** Whether the task is complete */
|
||||
completed: boolean;
|
||||
/** Task type - persistent (event-based) or dynamic (stat-based) */
|
||||
type: TaskType;
|
||||
/** Action to perform (if applicable) */
|
||||
action?: 'navigate' | 'open_modal' | 'external_link';
|
||||
/** Target for the action */
|
||||
actionTarget?: string;
|
||||
/** Button label */
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of computing hatch tasks.
|
||||
*/
|
||||
export interface HatchTasksResult {
|
||||
tasks: HatchTask[];
|
||||
/** All persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Dynamic stat task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
/** All tasks (persistent + dynamic) are complete - required to hatch */
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Refetch task progress */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi hatch post.
|
||||
* Must contain the required prefix and all required hashtags including the Blobbi name.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
|
||||
*/
|
||||
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
|
||||
// Check content starts with prefix
|
||||
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required hashtags in tags
|
||||
const hashtags = event.tags
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
);
|
||||
|
||||
if (!hasRequiredHashtags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blobbi name hashtag must also be present
|
||||
const blobbiHashtag = sanitizeToHashtag(blobbiName);
|
||||
return hashtags.includes(blobbiHashtag);
|
||||
}
|
||||
|
||||
// Legacy function name for backwards compatibility
|
||||
export const isValidBlobbiPost = isValidHatchPost;
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to compute hatch task progress from Nostr events and current stats.
|
||||
*
|
||||
* PERSISTENT TASKS (event-based, can be cached):
|
||||
* 1. Create Theme (kind 36767) - ≥1 event after start
|
||||
* 2. Color Moment (kind 3367) - ≥1 event after start
|
||||
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
|
||||
* 4. Interactions - 7 total (tracked via companion.tasks cache)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 5. Maintain Stats - health >= 70, hygiene >= 70, happiness >= 70
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be incubating)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
*/
|
||||
export function useHatchTasks(
|
||||
companion: BlobbiCompanion | null,
|
||||
interactionCount?: number
|
||||
): HatchTasksResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const stateStartedAt = companion?.stateStartedAt;
|
||||
const isIncubating = companion?.state === 'incubating';
|
||||
|
||||
// Query for all relevant events
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['hatch-tasks', pubkey, stateStartedAt],
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !stateStartedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build filters for events we need
|
||||
const filters: NostrFilter[] = [
|
||||
// Theme definitions after start
|
||||
{
|
||||
kinds: [KIND_THEME_DEFINITION],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Color moments after start
|
||||
{
|
||||
kinds: [KIND_COLOR_MOMENT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Posts after start (will filter for valid Blobbi posts)
|
||||
{
|
||||
kinds: [KIND_SHORT_TEXT_NOTE],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Reasonable limit
|
||||
},
|
||||
// Profile metadata - need both before and after start
|
||||
// Get latest before start
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
until: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
// Get latest after start
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Execute all queries
|
||||
const events = await nostr.query(filters);
|
||||
|
||||
// Categorize events
|
||||
const themeEvents = events.filter(e =>
|
||||
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const colorMomentEvents = events.filter(e =>
|
||||
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const postEvents = events.filter(e =>
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Separate profile events into before and after
|
||||
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
|
||||
const profileBefore = profileEvents
|
||||
.filter(e => e.created_at < stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
const profileAfter = profileEvents
|
||||
.filter(e => e.created_at >= stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
return {
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
profileBefore,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey && !!stateStartedAt && isIncubating,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchInterval: 60_000, // Refetch every minute
|
||||
});
|
||||
|
||||
// ─── Compute PERSISTENT Tasks ───
|
||||
const tasks: HatchTask[] = [];
|
||||
|
||||
// 1. Create Theme (PERSISTENT)
|
||||
const hasTheme = (data?.themeEvents?.length ?? 0) >= 1;
|
||||
tasks.push({
|
||||
id: 'create_theme',
|
||||
name: 'Create Theme',
|
||||
description: 'Create a custom theme for your profile',
|
||||
current: hasTheme ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasTheme,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/themes',
|
||||
actionLabel: 'Create Theme',
|
||||
});
|
||||
|
||||
// 2. Color Moment (PERSISTENT)
|
||||
const hasColorMoment = (data?.colorMomentEvents?.length ?? 0) >= 1;
|
||||
tasks.push({
|
||||
id: 'color_moment',
|
||||
name: 'Color Moment',
|
||||
description: 'Share a color moment on espy',
|
||||
current: hasColorMoment ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasColorMoment,
|
||||
type: 'persistent',
|
||||
action: 'external_link',
|
||||
actionTarget: 'https://espy.you/',
|
||||
actionLabel: 'Open espy',
|
||||
});
|
||||
|
||||
// 3. Create Post (PERSISTENT)
|
||||
const blobbiName = companion?.name ?? '';
|
||||
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
|
||||
const hasValidPost = validPosts.length >= 1;
|
||||
tasks.push({
|
||||
id: 'create_post',
|
||||
name: 'Create Post',
|
||||
description: 'Share a post about hatching your Blobbi',
|
||||
current: hasValidPost ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasValidPost,
|
||||
type: 'persistent',
|
||||
action: 'open_modal',
|
||||
actionTarget: 'blobbi_post',
|
||||
actionLabel: 'Create Post',
|
||||
});
|
||||
|
||||
// 5. Interactions (PERSISTENT)
|
||||
const interactions = interactionCount ?? 0;
|
||||
const interactionsCompleted = interactions >= HATCH_REQUIRED_INTERACTIONS;
|
||||
tasks.push({
|
||||
id: 'interactions',
|
||||
name: 'Interact with Blobbi',
|
||||
description: `Care for your Blobbi ${HATCH_REQUIRED_INTERACTIONS} times`,
|
||||
current: Math.min(interactions, HATCH_REQUIRED_INTERACTIONS),
|
||||
required: HATCH_REQUIRED_INTERACTIONS,
|
||||
completed: interactionsCompleted,
|
||||
type: 'persistent',
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
// 6. Maintain Stats - health >= 70, hygiene >= 70, happiness >= 70
|
||||
const stats = companion?.stats ?? {};
|
||||
const health = stats.health ?? 0;
|
||||
const hygiene = stats.hygiene ?? 0;
|
||||
const happiness = stats.happiness ?? 0;
|
||||
|
||||
const statsOk =
|
||||
health >= HATCH_STAT_THRESHOLD &&
|
||||
hygiene >= HATCH_STAT_THRESHOLD &&
|
||||
happiness >= HATCH_STAT_THRESHOLD;
|
||||
|
||||
// Calculate minimum stat for progress display
|
||||
const minStat = Math.min(health, hygiene, happiness);
|
||||
|
||||
tasks.push({
|
||||
id: 'maintain_stats',
|
||||
name: 'Keep Egg Healthy',
|
||||
description: `Keep health, hygiene & happiness above ${HATCH_STAT_THRESHOLD}`,
|
||||
current: statsOk ? HATCH_STAT_THRESHOLD : minStat,
|
||||
required: HATCH_STAT_THRESHOLD,
|
||||
completed: statsOk,
|
||||
type: 'dynamic', // CRITICAL: Never persist this task
|
||||
// No action - just care for your Blobbi
|
||||
});
|
||||
|
||||
// ─── Compute Completion States ───
|
||||
const persistentTasks = tasks.filter(t => t.type === 'persistent');
|
||||
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
|
||||
|
||||
const persistentTasksComplete = persistentTasks.every(t => t.completed);
|
||||
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
|
||||
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
|
||||
|
||||
return {
|
||||
tasks,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current interaction count from companion task cache.
|
||||
*/
|
||||
export function getInteractionCount(companion: BlobbiCompanion | null): number {
|
||||
if (!companion) return 0;
|
||||
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
|
||||
return interactionTask?.value ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tasks to only persistent tasks (for tag sync).
|
||||
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
|
||||
*/
|
||||
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'persistent');
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* useRerollMission - Hook for rerolling daily missions
|
||||
*
|
||||
* Handles:
|
||||
* - Replacing a mission with a new one from the pool
|
||||
* - Tracking reroll usage (max 3 per day)
|
||||
* - Respecting stage-based mission filtering
|
||||
* - Persisting state to localStorage
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
rerollMission,
|
||||
canRerollMission,
|
||||
getRerollsRemaining,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RerollMissionRequest {
|
||||
missionId: string;
|
||||
availableStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
export interface RerollMissionResult {
|
||||
oldMissionId: string;
|
||||
newMission: DailyMission;
|
||||
rerollsRemaining: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
const state = JSON.parse(stored) as DailyMissionsState;
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state.rerollsRemaining === undefined) {
|
||||
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useRerollMission] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to reroll a daily mission.
|
||||
*
|
||||
* Replaces the specified mission with a new one from the pool,
|
||||
* respecting stage-based filtering and avoiding duplicates.
|
||||
*/
|
||||
export function useRerollMission() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to reroll missions');
|
||||
}
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
|
||||
}
|
||||
|
||||
// Check if reroll is allowed
|
||||
if (!canRerollMission(missionsState!, missionId)) {
|
||||
const rerollsLeft = getRerollsRemaining(missionsState!);
|
||||
if (rerollsLeft <= 0) {
|
||||
throw new Error('No rerolls remaining today');
|
||||
}
|
||||
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (mission?.completed || mission?.claimed) {
|
||||
throw new Error('Cannot reroll completed or claimed missions');
|
||||
}
|
||||
|
||||
throw new Error('Cannot reroll this mission');
|
||||
}
|
||||
|
||||
// Perform the reroll
|
||||
const result = rerollMission(missionsState!, missionId, availableStages);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
|
||||
}
|
||||
|
||||
// Persist the updated state
|
||||
writeMissionsState(result.state);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: {
|
||||
missionId,
|
||||
rerolled: true,
|
||||
newMissionId: result.newMission.id,
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
oldMissionId: missionId,
|
||||
newMission: result.newMission,
|
||||
rerollsRemaining: getRerollsRemaining(result.state),
|
||||
};
|
||||
},
|
||||
onSuccess: ({ newMission, rerollsRemaining }) => {
|
||||
const rerollText = rerollsRemaining === 1
|
||||
? '1 reroll left'
|
||||
: rerollsRemaining === 0
|
||||
? 'No rerolls left'
|
||||
: `${rerollsRemaining} rerolls left`;
|
||||
|
||||
toast({
|
||||
title: 'Mission Replaced',
|
||||
description: `New mission: ${newMission.title}. ${rerollText}.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to Reroll',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// src/blobbi/actions/index.ts
|
||||
|
||||
// Components
|
||||
export { BlobbiActionsModal } from './components/BlobbiActionsModal';
|
||||
export { BlobbiActionInventoryModal } from './components/BlobbiActionInventoryModal';
|
||||
export { PlayMusicModal } from './components/PlayMusicModal';
|
||||
export { SingModal } from './components/SingModal';
|
||||
export { InlineMusicPlayer } from './components/InlineMusicPlayer';
|
||||
export { InlineSingCard } from './components/InlineSingCard';
|
||||
export { HatchTasksPanel } from './components/HatchTasksPanel';
|
||||
export { TasksPanel } from './components/TasksPanel';
|
||||
export { BlobbiPostModal } from './components/BlobbiPostModal';
|
||||
export { StartIncubationDialog } from './components/StartIncubationDialog';
|
||||
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
|
||||
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
|
||||
export type { AudioSource } from './components/PlayMusicModal';
|
||||
|
||||
// Hooks
|
||||
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
|
||||
export type { UseItemRequest, UseItemResult, UseBlobbiUseInventoryItemParams } from './hooks/useBlobbiUseInventoryItem';
|
||||
|
||||
export { useBlobbiHatch, useBlobbiEvolve } from './hooks/useBlobbiStageTransition';
|
||||
export type {
|
||||
UseBlobbiStageTransitionParams,
|
||||
StageTransitionResult,
|
||||
CanonicalActionResult,
|
||||
} from './hooks/useBlobbiStageTransition';
|
||||
|
||||
export {
|
||||
useStartIncubation,
|
||||
useStopIncubation,
|
||||
useStartEvolution,
|
||||
useStopEvolution,
|
||||
useSyncTaskCompletions,
|
||||
} from './hooks/useBlobbiIncubation';
|
||||
export type {
|
||||
StartIncubationMode,
|
||||
StartIncubationRequest,
|
||||
UseStartIncubationParams,
|
||||
StartIncubationResult,
|
||||
UseStopIncubationParams,
|
||||
StopIncubationResult,
|
||||
UseStartEvolutionParams,
|
||||
StartEvolutionResult,
|
||||
UseStopEvolutionParams,
|
||||
StopEvolutionResult,
|
||||
UseSyncTaskCompletionsParams,
|
||||
TaskCompletionToSync,
|
||||
} from './hooks/useBlobbiIncubation';
|
||||
|
||||
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
|
||||
export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from './hooks/useActiveTaskProcess';
|
||||
|
||||
export {
|
||||
useHatchTasks,
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
HATCH_STAT_THRESHOLD,
|
||||
REQUIRED_INTERACTIONS, // Legacy export
|
||||
BLOBBI_POST_PREFIX,
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
} from './hooks/useHatchTasks';
|
||||
export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTasks';
|
||||
|
||||
export {
|
||||
useEvolveTasks,
|
||||
getEvolveInteractionCount,
|
||||
isValidEvolvePost,
|
||||
KIND_WALL_EDIT,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_POSTS,
|
||||
EVOLVE_REQUIRED_INTERACTIONS,
|
||||
EVOLVE_STAT_THRESHOLD,
|
||||
BLOBBI_EVOLVE_POST_PREFIX,
|
||||
} from './hooks/useEvolveTasks';
|
||||
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
|
||||
|
||||
export { useBlobbiDirectAction, DIRECT_ACTION_HAPPINESS_EFFECTS } from './hooks/useBlobbiDirectAction';
|
||||
export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionParams } from './hooks/useBlobbiDirectAction';
|
||||
|
||||
export { useAudioPlayback } from './hooks/useAudioPlayback';
|
||||
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
|
||||
|
||||
// Built-in tracks
|
||||
export {
|
||||
BLOBBI_BUILTIN_TRACKS,
|
||||
getAllBuiltInTracks,
|
||||
getBuiltInTrackById,
|
||||
formatTrackDuration,
|
||||
type BuiltInTrack,
|
||||
} from './lib/blobbi-builtin-tracks';
|
||||
|
||||
// Activity state
|
||||
export {
|
||||
createMusicActivity,
|
||||
createSingActivity,
|
||||
createNoActivity,
|
||||
type InlineActivityType,
|
||||
type InlineActivityState,
|
||||
type MusicActivityState,
|
||||
type SingActivityState,
|
||||
type NoActivityState,
|
||||
type BlobbiReactionState,
|
||||
type MusicTrackSource,
|
||||
} from './lib/blobbi-activity-state';
|
||||
|
||||
// Re-export stat bounds from canonical source
|
||||
export { STAT_MIN, STAT_MAX } from '@/lib/blobbi';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
// Types
|
||||
type InventoryAction,
|
||||
type DirectAction,
|
||||
type BlobbiAction,
|
||||
type ResolvedInventoryItem,
|
||||
type EggStatPreview,
|
||||
type ItemUsabilityResult,
|
||||
type IncrementInteractionResult,
|
||||
// Constants
|
||||
ACTION_TO_ITEM_TYPE,
|
||||
ACTION_METADATA,
|
||||
DIRECT_ACTION_METADATA,
|
||||
ALL_ACTION_METADATA,
|
||||
GENERAL_ITEM_USABLE_STAGES,
|
||||
EGG_ALLOWED_ACTIONS,
|
||||
EGG_ALLOWED_INVENTORY_ACTIONS,
|
||||
EGG_ALLOWED_DIRECT_ACTIONS,
|
||||
EGG_VISIBLE_INVENTORY_ACTIONS,
|
||||
EGG_VISIBLE_ACTIONS,
|
||||
SHELL_REPAIR_KIT_ID,
|
||||
// Functions
|
||||
clampStat,
|
||||
applyStat,
|
||||
applyItemEffects,
|
||||
filterInventoryByAction,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
canUseDirectAction,
|
||||
isActionVisibleForStage,
|
||||
canUseInventoryItems,
|
||||
getStageRestrictionMessage,
|
||||
previewStatChanges,
|
||||
previewMedicineForEgg,
|
||||
previewCleanForEgg,
|
||||
hasMedicineEffectForEgg,
|
||||
hasHygieneEffectForEgg,
|
||||
canUseItemForStage,
|
||||
getActionForItem,
|
||||
incrementInteractionTaskTags,
|
||||
} from './lib/blobbi-action-utils';
|
||||
|
||||
// Daily Missions
|
||||
export { useDailyMissions } from './hooks/useDailyMissions';
|
||||
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
|
||||
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
|
||||
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
|
||||
export {
|
||||
trackDailyMissionProgress,
|
||||
trackMultipleDailyMissionActions,
|
||||
} from './lib/daily-mission-tracker';
|
||||
export type {
|
||||
DailyMission,
|
||||
DailyMissionAction,
|
||||
DailyMissionDefinition,
|
||||
DailyMissionsState,
|
||||
} from './lib/daily-missions';
|
||||
|
||||
// Streak tracking
|
||||
export {
|
||||
calculateStreakUpdate,
|
||||
getStreakTagUpdates,
|
||||
needsStreakUpdate,
|
||||
getStreakStatus,
|
||||
} from './lib/blobbi-streak';
|
||||
export type {
|
||||
StreakUpdateResult,
|
||||
StreakTagUpdates,
|
||||
} from './lib/blobbi-streak';
|
||||
|
||||
export { useBlobbiCareActivity } from './hooks/useBlobbiCareActivity';
|
||||
export type {
|
||||
UseBlobbiCareActivityParams,
|
||||
CareActivityResult,
|
||||
} from './hooks/useBlobbiCareActivity';
|
||||
@@ -0,0 +1,639 @@
|
||||
// src/blobbi/actions/lib/blobbi-action-utils.ts
|
||||
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/lib/blobbi';
|
||||
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
|
||||
// ─── Action Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Actions that consume inventory items
|
||||
*/
|
||||
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
|
||||
|
||||
/**
|
||||
* Non-inventory actions that don't consume items
|
||||
* These actions affect stats directly without using shop items.
|
||||
*/
|
||||
export type DirectAction = 'play_music' | 'sing';
|
||||
|
||||
/**
|
||||
* All Blobbi actions (inventory + direct)
|
||||
*/
|
||||
export type BlobbiAction = InventoryAction | DirectAction;
|
||||
|
||||
/**
|
||||
* Mapping from action type to allowed item categories
|
||||
*/
|
||||
export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
|
||||
feed: 'food',
|
||||
play: 'toy',
|
||||
clean: 'hygiene',
|
||||
medicine: 'medicine',
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for UI display (inventory actions)
|
||||
*/
|
||||
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
|
||||
feed: {
|
||||
label: 'Feed',
|
||||
description: 'Feed your Blobbi',
|
||||
icon: '🍎',
|
||||
},
|
||||
play: {
|
||||
label: 'Play',
|
||||
description: 'Play with your Blobbi',
|
||||
icon: '⚽',
|
||||
},
|
||||
clean: {
|
||||
label: 'Clean',
|
||||
description: 'Clean your Blobbi',
|
||||
icon: '🧼',
|
||||
},
|
||||
medicine: {
|
||||
label: 'Medicine',
|
||||
description: 'Heal your Blobbi',
|
||||
icon: '💊',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for direct actions (non-inventory)
|
||||
*/
|
||||
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
|
||||
play_music: {
|
||||
label: 'Play Music',
|
||||
description: 'Play music for your Blobbi',
|
||||
icon: '🎵',
|
||||
},
|
||||
sing: {
|
||||
label: 'Sing',
|
||||
description: 'Sing to your Blobbi',
|
||||
icon: '🎤',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined action metadata for all action types
|
||||
*/
|
||||
export const ALL_ACTION_METADATA: Record<BlobbiAction, { label: string; description: string; icon: string }> = {
|
||||
...ACTION_METADATA,
|
||||
...DIRECT_ACTION_METADATA,
|
||||
};
|
||||
|
||||
// ─── Stat Helpers ─────────────────────────────────────────────────────────────
|
||||
// STAT_MIN and STAT_MAX are imported from @/lib/blobbi (single source of truth)
|
||||
|
||||
/**
|
||||
* Clamp a stat value between STAT_MIN (1) and STAT_MAX (100).
|
||||
* Safe for undefined values (returns STAT_MIN).
|
||||
*
|
||||
* The minimum of 1 (instead of 0) ensures:
|
||||
* - Blobbi is never in an unrecoverable state
|
||||
* - Visual feedback shows critical state without being "dead"
|
||||
* - Recovery is always possible with any healing item
|
||||
*/
|
||||
export function clampStat(value: number | undefined): number {
|
||||
if (value === undefined) return STAT_MIN;
|
||||
return Math.max(STAT_MIN, Math.min(STAT_MAX, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a delta to a stat, clamping the result to STAT_MIN-STAT_MAX.
|
||||
*/
|
||||
export function applyStat(current: number | undefined, delta: number): number {
|
||||
const currentValue = current ?? STAT_MIN;
|
||||
return clampStat(currentValue + delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply item effects to current stats.
|
||||
* Returns a new partial stats object with all affected stats clamped.
|
||||
* Only modifies stats that have corresponding effects.
|
||||
*/
|
||||
export function applyItemEffects(
|
||||
currentStats: Partial<BlobbiStats>,
|
||||
effects: ItemEffect
|
||||
): Partial<BlobbiStats> {
|
||||
const newStats: Partial<BlobbiStats> = { ...currentStats };
|
||||
|
||||
if (effects.hunger !== undefined) {
|
||||
newStats.hunger = applyStat(currentStats.hunger, effects.hunger);
|
||||
}
|
||||
if (effects.happiness !== undefined) {
|
||||
newStats.happiness = applyStat(currentStats.happiness, effects.happiness);
|
||||
}
|
||||
if (effects.energy !== undefined) {
|
||||
newStats.energy = applyStat(currentStats.energy, effects.energy);
|
||||
}
|
||||
if (effects.hygiene !== undefined) {
|
||||
newStats.hygiene = applyStat(currentStats.hygiene, effects.hygiene);
|
||||
}
|
||||
if (effects.health !== undefined) {
|
||||
newStats.health = applyStat(currentStats.health, effects.health);
|
||||
}
|
||||
|
||||
return newStats;
|
||||
}
|
||||
|
||||
// ─── Egg-Specific Item Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Shell Repair Kit is a special medicine item only usable by eggs.
|
||||
*/
|
||||
export const SHELL_REPAIR_KIT_ID = 'med_shell_repair';
|
||||
|
||||
/**
|
||||
* Result of checking if an item can be used by a specific Blobbi stage.
|
||||
*/
|
||||
export interface ItemUsabilityResult {
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific item can be used by a companion at the given stage.
|
||||
*
|
||||
* This is the centralized item usability logic:
|
||||
* - Shell Repair Kit: Only usable by eggs
|
||||
* - Food items: Only usable by baby/adult (not eggs)
|
||||
* - Toy items: Only usable by baby/adult (not eggs)
|
||||
* - Medicine items (except Shell Repair Kit): Usable by all stages with health effect
|
||||
* - Hygiene items: Usable by all stages
|
||||
*
|
||||
* @param itemId - The shop item ID
|
||||
* @param stage - The companion's life stage
|
||||
* @returns Object with canUse boolean and optional reason string
|
||||
*/
|
||||
export function canUseItemForStage(
|
||||
itemId: string,
|
||||
stage: 'egg' | 'baby' | 'adult'
|
||||
): ItemUsabilityResult {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) {
|
||||
return { canUse: false, reason: 'Item not found' };
|
||||
}
|
||||
|
||||
const isEgg = stage === 'egg';
|
||||
|
||||
// Shell Repair Kit special case: only for eggs
|
||||
if (itemId === SHELL_REPAIR_KIT_ID) {
|
||||
if (!isEgg) {
|
||||
return { canUse: false, reason: 'Only usable for eggs' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Food items: not usable by eggs
|
||||
if (shopItem.type === 'food') {
|
||||
if (isEgg) {
|
||||
return { canUse: false, reason: 'Eggs cannot eat food' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Toy items: not usable by eggs
|
||||
if (shopItem.type === 'toy') {
|
||||
if (isEgg) {
|
||||
return { canUse: false, reason: 'Eggs cannot use toys' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Medicine items (except Shell Repair Kit): check for health effect
|
||||
if (shopItem.type === 'medicine') {
|
||||
if (!hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
return { canUse: false, reason: 'This medicine has no effect' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Hygiene items: all stages can use
|
||||
if (shopItem.type === 'hygiene') {
|
||||
if (!hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
|
||||
return { canUse: false, reason: 'This item has no cleaning effect' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Accessories are disabled
|
||||
if (shopItem.type === 'accessory') {
|
||||
return { canUse: false, reason: 'Accessories are not usable yet' };
|
||||
}
|
||||
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action type for a given item.
|
||||
*/
|
||||
export function getActionForItem(itemId: string): InventoryAction | null {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) return null;
|
||||
|
||||
const typeToAction: Record<string, InventoryAction> = {
|
||||
food: 'feed',
|
||||
toy: 'play',
|
||||
hygiene: 'clean',
|
||||
medicine: 'medicine',
|
||||
};
|
||||
|
||||
return typeToAction[shopItem.type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a medicine item has any effect on an egg.
|
||||
*
|
||||
* Eggs use the standard 3-stat model:
|
||||
* - health
|
||||
* - hygiene
|
||||
* - happiness
|
||||
*
|
||||
* Medicine with a health effect will directly affect the egg's health stat.
|
||||
*/
|
||||
export function hasMedicineEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.health !== undefined && effects.health !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hygiene item has any effect on an egg.
|
||||
* Hygiene items with a hygiene effect will directly affect the egg's hygiene stat.
|
||||
*/
|
||||
export function hasHygieneEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.hygiene !== undefined && effects.hygiene !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item has a happiness effect for an egg.
|
||||
* Some items (like bubble bath) give happiness bonus in addition to primary effects.
|
||||
*/
|
||||
export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.happiness !== undefined && effects.happiness !== 0;
|
||||
}
|
||||
|
||||
// ─── Inventory Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved inventory item with shop metadata
|
||||
*/
|
||||
export interface ResolvedInventoryItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
type: ShopItemCategory;
|
||||
effect?: ItemEffect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering inventory by action
|
||||
*/
|
||||
export interface FilterInventoryOptions {
|
||||
/** Companion stage - used to filter items by egg-compatible effects */
|
||||
stage?: 'egg' | 'baby' | 'adult';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter inventory items by action type.
|
||||
* Returns resolved items with shop metadata.
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Only items matching the action's item type are included
|
||||
* - Shell Repair Kit only appears in medicine modal for eggs
|
||||
* - For eggs: only items with egg-compatible effects are returned
|
||||
* - medicine action: only items with health effect
|
||||
* - clean action: only items with hygiene or happiness effect
|
||||
*/
|
||||
export function filterInventoryByAction(
|
||||
storage: StorageItem[],
|
||||
action: InventoryAction,
|
||||
options: FilterInventoryOptions = {}
|
||||
): ResolvedInventoryItem[] {
|
||||
const allowedType = ACTION_TO_ITEM_TYPE[action];
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
const isEgg = options.stage === 'egg';
|
||||
|
||||
for (const storageItem of storage) {
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For eggs, filter items by egg-compatible effects
|
||||
if (isEgg) {
|
||||
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
continue; // Skip medicine without health effect
|
||||
}
|
||||
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
|
||||
continue; // Skip hygiene items without hygiene or happiness effect
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
name: shopItem.name,
|
||||
icon: shopItem.icon,
|
||||
type: shopItem.type,
|
||||
effect: shopItem.effect,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement item quantity in storage array.
|
||||
* If quantity becomes 0, removes the item entirely.
|
||||
* Returns a new storage array (immutable).
|
||||
*/
|
||||
export function decrementStorageItem(
|
||||
storage: StorageItem[],
|
||||
itemId: string,
|
||||
amount = 1
|
||||
): StorageItem[] {
|
||||
const result: StorageItem[] = [];
|
||||
|
||||
for (const item of storage) {
|
||||
if (item.itemId !== itemId) {
|
||||
result.push(item);
|
||||
continue;
|
||||
}
|
||||
const newQuantity = item.quantity - amount;
|
||||
if (newQuantity > 0) {
|
||||
result.push({ ...item, quantity: newQuantity });
|
||||
}
|
||||
// If newQuantity <= 0, we don't add it (remove item)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stages that can use general inventory items (food, toys, hygiene)
|
||||
*/
|
||||
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
|
||||
|
||||
/**
|
||||
* Inventory actions that are allowed for eggs.
|
||||
* Eggs can use: medicine (health), clean (hygiene)
|
||||
*/
|
||||
export const EGG_ALLOWED_INVENTORY_ACTIONS: InventoryAction[] = ['medicine', 'clean'];
|
||||
|
||||
/**
|
||||
* Direct actions that are allowed for eggs.
|
||||
* All direct actions work on eggs.
|
||||
*/
|
||||
export const EGG_ALLOWED_DIRECT_ACTIONS: DirectAction[] = ['play_music', 'sing'];
|
||||
|
||||
/**
|
||||
* Inventory actions visible in the egg UI.
|
||||
* Note: feed, play, sleep are hidden in the UI for eggs but not hard-blocked.
|
||||
*/
|
||||
export const EGG_VISIBLE_INVENTORY_ACTIONS: InventoryAction[] = ['clean', 'medicine'];
|
||||
|
||||
/**
|
||||
* All actions visible in the egg UI.
|
||||
*/
|
||||
export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_music', 'sing'];
|
||||
|
||||
/**
|
||||
* @deprecated Use EGG_ALLOWED_INVENTORY_ACTIONS instead
|
||||
*/
|
||||
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific inventory 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.
|
||||
// UI filtering determines what actions are shown to users.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific direct action.
|
||||
* Direct actions (play_music, sing) are available for all stages.
|
||||
*/
|
||||
export function canUseDirectAction(_companion: BlobbiCompanion, _action: DirectAction): boolean {
|
||||
// All stages can use direct actions
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action should be visible in the UI for a given stage.
|
||||
* This is for UI filtering only - some actions are hidden but not blocked.
|
||||
*/
|
||||
export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action: BlobbiAction): boolean {
|
||||
if (stage === 'egg') {
|
||||
return EGG_VISIBLE_ACTIONS.includes(action);
|
||||
}
|
||||
return true; // baby and adult see all actions
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use general inventory items (feed, play, clean).
|
||||
* Eggs cannot use food, toys, or hygiene items.
|
||||
* @deprecated Use canUseAction(companion, action) for action-specific checks
|
||||
*/
|
||||
export function canUseInventoryItems(companion: BlobbiCompanion): boolean {
|
||||
return GENERAL_ITEM_USABLE_STAGES.includes(companion.stage as typeof GENERAL_ITEM_USABLE_STAGES[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message explaining why an action can't be used.
|
||||
*/
|
||||
export function getStageRestrictionMessage(companion: BlobbiCompanion, action?: InventoryAction): string | null {
|
||||
if (companion.stage === 'egg') {
|
||||
if (action && EGG_ALLOWED_INVENTORY_ACTIONS.includes(action)) {
|
||||
return null; // Medicine and clean are allowed for eggs
|
||||
}
|
||||
return 'Eggs cannot use this item. Wait for your Blobbi to hatch!';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Stats Preview ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preview stats after applying an item's effects.
|
||||
* Useful for showing the user what will happen before confirming.
|
||||
*/
|
||||
export function previewStatChanges(
|
||||
currentStats: Partial<BlobbiStats>,
|
||||
effects: ItemEffect | undefined
|
||||
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
|
||||
if (!effects) return [];
|
||||
|
||||
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
|
||||
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'energy', 'hygiene', 'health'];
|
||||
|
||||
for (const stat of statKeys) {
|
||||
const delta = effects[stat];
|
||||
if (delta !== undefined && delta !== 0) {
|
||||
const current = currentStats[stat] ?? 0;
|
||||
const after = clampStat(current + delta);
|
||||
changes.push({ stat, current, after, delta });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview stat change for an egg.
|
||||
* Eggs use the 3-stat model: health, hygiene, happiness.
|
||||
*/
|
||||
export type EggStatPreview = { stat: 'health' | 'hygiene' | 'happiness'; current: number; after: number; delta: number };
|
||||
|
||||
/**
|
||||
* Preview medicine effects for an egg.
|
||||
* Medicine directly affects the egg's health stat.
|
||||
*/
|
||||
export function previewMedicineForEgg(
|
||||
currentHealth: number | undefined,
|
||||
effects: ItemEffect | undefined
|
||||
): EggStatPreview[] {
|
||||
if (!effects || effects.health === undefined || effects.health === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const current = currentHealth ?? 100;
|
||||
const delta = effects.health;
|
||||
const after = clampStat(current + delta);
|
||||
|
||||
return [{ stat: 'health', current, after, delta }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview clean (hygiene) effects for an egg.
|
||||
* Hygiene items directly affect the egg's hygiene stat.
|
||||
* May also include happiness bonus if the item has one.
|
||||
*/
|
||||
export function previewCleanForEgg(
|
||||
currentStats: { hygiene?: number; happiness?: number },
|
||||
effects: ItemEffect | undefined
|
||||
): EggStatPreview[] {
|
||||
if (!effects) return [];
|
||||
|
||||
const results: EggStatPreview[] = [];
|
||||
|
||||
// Hygiene effect
|
||||
if (effects.hygiene !== undefined && effects.hygiene !== 0) {
|
||||
const current = currentStats.hygiene ?? 100;
|
||||
const delta = effects.hygiene;
|
||||
const after = clampStat(current + delta);
|
||||
results.push({ stat: 'hygiene', current, after, delta });
|
||||
}
|
||||
|
||||
// Happiness bonus (some hygiene items like bubble bath give happiness)
|
||||
if (effects.happiness !== undefined && effects.happiness !== 0) {
|
||||
const current = currentStats.happiness ?? 100;
|
||||
const delta = effects.happiness;
|
||||
const after = clampStat(current + delta);
|
||||
results.push({ stat: 'happiness', current, after, delta });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** Enable debug logging in development only */
|
||||
const DEBUG_INTERACTION_TASK = import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Result of incrementing interaction task tags
|
||||
*/
|
||||
export interface IncrementInteractionResult {
|
||||
/** Updated tags array */
|
||||
updatedTags: string[][];
|
||||
/** New interaction count after increment */
|
||||
newCount: number;
|
||||
/** Whether the task is now complete */
|
||||
isCompleted: boolean;
|
||||
/** Previous count before increment */
|
||||
previousCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the interaction task counter in the tags array.
|
||||
*
|
||||
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
|
||||
* to track progress on interaction tasks for both hatch and evolve.
|
||||
*
|
||||
* CRITICAL: This function is called during actual user actions (not retroactive sync).
|
||||
* It always increments by 1 because each call represents a real interaction.
|
||||
*
|
||||
* Tag format:
|
||||
* - Progress: ["task", "interactions:N"]
|
||||
* - Completion: ["task_completed", "interactions"]
|
||||
*
|
||||
* Idempotency notes:
|
||||
* - This is NOT idempotent by design - each call = one interaction
|
||||
* - Duplicate task_completed tags are prevented by filtering before add
|
||||
* - Multiple task:interactions tags are prevented by filtering before add
|
||||
*
|
||||
* @param currentTags - Current tags array from the Blobbi state
|
||||
* @param requiredInteractions - Threshold for completion (7 for hatch, 21 for evolve)
|
||||
* @returns Updated tags array with incremented interaction count
|
||||
*/
|
||||
export function incrementInteractionTaskTags(
|
||||
currentTags: string[][],
|
||||
requiredInteractions: number
|
||||
): IncrementInteractionResult {
|
||||
// Get current interaction count from task tags
|
||||
const interactionTag = currentTags.find(tag =>
|
||||
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
|
||||
);
|
||||
const previousCount = interactionTag
|
||||
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
|
||||
: 0;
|
||||
const newCount = previousCount + 1;
|
||||
|
||||
// Check if already completed (task_completed tag exists)
|
||||
const alreadyCompleted = currentTags.some(tag =>
|
||||
tag[0] === 'task_completed' && tag[1] === 'interactions'
|
||||
);
|
||||
|
||||
// Remove old interaction task tag (prevent duplicates) and add new one
|
||||
let updatedTags = currentTags.filter(tag =>
|
||||
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
|
||||
);
|
||||
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
|
||||
|
||||
// Mark as completed if reached required count AND not already marked
|
||||
const isCompleted = newCount >= requiredInteractions;
|
||||
if (isCompleted && !alreadyCompleted) {
|
||||
// Only add if not already present (handled by filter, but double-check)
|
||||
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
|
||||
}
|
||||
|
||||
if (DEBUG_INTERACTION_TASK) {
|
||||
console.log('[InteractionTask] Increment:', {
|
||||
previousCount,
|
||||
newCount,
|
||||
requiredInteractions,
|
||||
isCompleted,
|
||||
alreadyCompleted,
|
||||
addedCompletionTag: isCompleted && !alreadyCompleted,
|
||||
});
|
||||
}
|
||||
|
||||
return { updatedTags, newCount, isCompleted, previousCount };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// src/blobbi/actions/lib/blobbi-activity-state.ts
|
||||
|
||||
import type { AudioSource } from '../components/PlayMusicModal';
|
||||
|
||||
/**
|
||||
* Types of inline activities that can be displayed in BlobbiPage
|
||||
*/
|
||||
export type InlineActivityType = 'none' | 'music' | 'sing';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { AudioSource as MusicTrackSource } from '../components/PlayMusicModal';
|
||||
|
||||
/**
|
||||
* State for the music inline activity
|
||||
*/
|
||||
export interface MusicActivityState {
|
||||
type: 'music';
|
||||
source: AudioSource;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the sing inline activity
|
||||
*/
|
||||
export interface SingActivityState {
|
||||
type: 'sing';
|
||||
}
|
||||
|
||||
/**
|
||||
* No active inline activity
|
||||
*/
|
||||
export interface NoActivityState {
|
||||
type: 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all inline activity states
|
||||
*/
|
||||
export type InlineActivityState =
|
||||
| NoActivityState
|
||||
| MusicActivityState
|
||||
| SingActivityState;
|
||||
|
||||
/**
|
||||
* Blobbi reaction state - indicates how Blobbi should visually react
|
||||
*/
|
||||
export type BlobbiReactionState =
|
||||
| 'idle' // No special reaction
|
||||
| 'listening' // Music is playing, Blobbi is listening
|
||||
| 'swaying' // Blobbi is swaying to music
|
||||
| 'singing' // User is singing, Blobbi is engaged
|
||||
| 'happy'; // General happy reaction
|
||||
|
||||
/**
|
||||
* Helper to create a music activity state
|
||||
*/
|
||||
export function createMusicActivity(source: AudioSource): MusicActivityState {
|
||||
return {
|
||||
type: 'music',
|
||||
source,
|
||||
isPublished: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a sing activity state
|
||||
*/
|
||||
export function createSingActivity(): SingActivityState {
|
||||
return {
|
||||
type: 'sing',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create no activity state
|
||||
*/
|
||||
export function createNoActivity(): NoActivityState {
|
||||
return {
|
||||
type: 'none',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// src/blobbi/actions/lib/blobbi-builtin-tracks.ts
|
||||
|
||||
/**
|
||||
* Built-in music tracks for the Blobbi "Play Music" action.
|
||||
*
|
||||
* ## Asset Location
|
||||
*
|
||||
* Audio files live in: `public/blobbi/audio/`
|
||||
*
|
||||
* In Vite, files in `public/` are served at root paths, so:
|
||||
* - `public/blobbi/audio/foo.mp3` → accessible at `/blobbi/audio/foo.mp3`
|
||||
*
|
||||
* ## Adding New Tracks
|
||||
*
|
||||
* 1. Place the MP3 file in `public/blobbi/audio/`
|
||||
* 2. Add a new entry to `BLOBBI_BUILTIN_TRACKS` below
|
||||
* 3. Set `path` to `/blobbi/audio/<filename>.mp3`
|
||||
* 4. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
|
||||
*
|
||||
* ## Supported Formats
|
||||
*
|
||||
* MP3 is recommended for maximum browser compatibility.
|
||||
* WAV, OGG, and M4A may work but are browser-dependent.
|
||||
*/
|
||||
|
||||
export interface BuiltInTrack {
|
||||
/** Unique identifier for the track (used in state/events) */
|
||||
id: string;
|
||||
/** Display title shown in the UI */
|
||||
title: string;
|
||||
/** Artist or source attribution */
|
||||
artist: string;
|
||||
/** Path to audio file (relative to public directory root) */
|
||||
path: string;
|
||||
/** Duration in seconds (for display, get via ffprobe) */
|
||||
durationSeconds: number;
|
||||
/** Optional cover art path (relative to public directory root) */
|
||||
coverArt?: string;
|
||||
/** Optional tags for categorization/filtering */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in track catalog for Blobbi music player.
|
||||
*
|
||||
* All tracks are royalty-free/Creative Commons licensed.
|
||||
* Audio files located at: public/blobbi/audio/
|
||||
*/
|
||||
export const BLOBBI_BUILTIN_TRACKS: BuiltInTrack[] = [
|
||||
{
|
||||
id: 'nap_in_the_meadow',
|
||||
title: 'Nap in the Meadow',
|
||||
artist: 'Chilltape FM',
|
||||
path: '/blobbi/audio/chilltapefm-nap-in-the-meadow.mp3',
|
||||
durationSeconds: 240, // 4:00
|
||||
tags: ['relaxing', 'nature'],
|
||||
},
|
||||
{
|
||||
id: 'happy_kids',
|
||||
title: 'Happy Kids',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
path: '/blobbi/audio/happy-kids.mp3',
|
||||
durationSeconds: 129, // 2:09
|
||||
tags: ['upbeat', 'fun'],
|
||||
},
|
||||
{
|
||||
id: 'soft_piano',
|
||||
title: 'Soft Piano',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
path: '/blobbi/audio/soft-piano.mp3',
|
||||
durationSeconds: 124, // 2:04
|
||||
tags: ['calming', 'sleep'],
|
||||
},
|
||||
{
|
||||
id: 'epic_sacred_light',
|
||||
title: 'Epic Sacred Light',
|
||||
artist: 'Ura Megis',
|
||||
path: '/blobbi/audio/epic-sacred-light.mp3',
|
||||
durationSeconds: 223, // 3:43
|
||||
tags: ['energetic', 'adventure'],
|
||||
},
|
||||
{
|
||||
id: 'split_memmories',
|
||||
title: 'Split Memmories',
|
||||
artist: 'ido berg',
|
||||
path: '/blobbi/audio/split-memmories.mp3',
|
||||
durationSeconds: 153, // 2:33
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
{
|
||||
id: 'minhas_mensagens',
|
||||
title: 'Minhas Mensagens',
|
||||
artist: 'PReis',
|
||||
path: '/blobbi/audio/minhas-mensagens-preis.mp3',
|
||||
durationSeconds: 248, // 4:08
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a built-in track by ID
|
||||
*/
|
||||
export function getBuiltInTrackById(id: string): BuiltInTrack | undefined {
|
||||
return BLOBBI_BUILTIN_TRACKS.find(track => track.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all built-in tracks
|
||||
*/
|
||||
export function getAllBuiltInTracks(): BuiltInTrack[] {
|
||||
return BLOBBI_BUILTIN_TRACKS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to MM:SS string
|
||||
*/
|
||||
export function formatTrackDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// src/blobbi/actions/lib/blobbi-random-lyrics.ts
|
||||
|
||||
/**
|
||||
* Random lyrics for the Sing action.
|
||||
* These are fun, simple lyrics that users can sing to their Blobbi.
|
||||
*/
|
||||
|
||||
export interface LyricsEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of placeholder lyrics for singing to a Blobbi.
|
||||
* Simple, fun, and appropriate for all ages.
|
||||
*/
|
||||
export const BLOBBI_LYRICS: LyricsEntry[] = [
|
||||
{
|
||||
id: 'lullaby-1',
|
||||
title: 'Blobbi Lullaby',
|
||||
lines: [
|
||||
'Little Blobbi, close your eyes,',
|
||||
'Dream of stars up in the skies.',
|
||||
'Safe and warm, you drift away,',
|
||||
"We'll play again another day.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'happy-song-1',
|
||||
title: 'Happy Blobbi Song',
|
||||
lines: [
|
||||
'Blobbi, Blobbi, jump around!',
|
||||
"You're the happiest friend I've found!",
|
||||
'Dancing, playing, full of cheer,',
|
||||
"I'm so glad that you are here!",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'adventure-1',
|
||||
title: 'Adventure Time',
|
||||
lines: [
|
||||
"Let's go on an adventure today,",
|
||||
'Through the clouds and far away!',
|
||||
'Mountains high and valleys deep,',
|
||||
'Memories to always keep.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'breakfast-song',
|
||||
title: 'Breakfast Song',
|
||||
lines: [
|
||||
'Wake up, wake up, sleepy head,',
|
||||
"Time to get out of your bed!",
|
||||
"Breakfast's waiting, fresh and yummy,",
|
||||
'Food to fill your happy tummy!',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rainy-day',
|
||||
title: 'Rainy Day',
|
||||
lines: [
|
||||
'Pitter patter on the roof,',
|
||||
'Rainy days can be so nice.',
|
||||
"We'll stay cozy, me and you,",
|
||||
'Watching raindrops, one by two.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sunshine-song',
|
||||
title: 'Sunshine Song',
|
||||
lines: [
|
||||
'Good morning, sunshine, bright and warm,',
|
||||
'A brand new day is being born!',
|
||||
'Blue sky smiling down on me,',
|
||||
'Happy as can be, so free!',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bedtime-1',
|
||||
title: 'Bedtime Blues',
|
||||
lines: [
|
||||
'The moon is up, the stars are bright,',
|
||||
'Time to say a soft goodnight.',
|
||||
'Snuggle up and close your eyes,',
|
||||
'Sweet dreams under starry skies.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'play-time',
|
||||
title: 'Play Time',
|
||||
lines: [
|
||||
"Bounce and jump and run around,",
|
||||
"Spin and twirl without a sound!",
|
||||
"Playing games is so much fun,",
|
||||
"Laughing underneath the sun!",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a random lyrics entry.
|
||||
*/
|
||||
export function getRandomLyrics(): LyricsEntry {
|
||||
const index = Math.floor(Math.random() * BLOBBI_LYRICS.length);
|
||||
return BLOBBI_LYRICS[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available lyrics entries.
|
||||
*/
|
||||
export function getAllLyrics(): LyricsEntry[] {
|
||||
return BLOBBI_LYRICS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format lyrics for display (joined with newlines).
|
||||
*/
|
||||
export function formatLyrics(lyrics: LyricsEntry): string {
|
||||
return lyrics.lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Blobbi Care Streak Management
|
||||
*
|
||||
* This module provides centralized logic for tracking care streaks on Blobbi companions.
|
||||
* A streak represents consecutive days of care activity (opening Blobbi page, performing
|
||||
* care actions, etc.).
|
||||
*
|
||||
* Streak Rules:
|
||||
* - Starts at 1 on first activity
|
||||
* - Increments when activity happens on the NEXT local calendar day
|
||||
* - Same-day activity does not increment (at most once per day)
|
||||
* - Missing 2+ days resets streak to 1
|
||||
*
|
||||
* Tags managed:
|
||||
* - care_streak: The current streak count (positive integer)
|
||||
* - care_streak_last_at: Unix timestamp (seconds) of last streak update
|
||||
* - care_streak_last_day: Local calendar day string (YYYY-MM-DD) of last update
|
||||
*/
|
||||
|
||||
import {
|
||||
getLocalDayString,
|
||||
getDaysDifference,
|
||||
type BlobbiCompanion,
|
||||
} from '@/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of calculating a streak update.
|
||||
*/
|
||||
export interface StreakUpdateResult {
|
||||
/** Whether the streak was updated (incremented or reset) */
|
||||
wasUpdated: boolean;
|
||||
/** The new streak value */
|
||||
newStreak: number;
|
||||
/** The new timestamp for care_streak_last_at */
|
||||
newLastAt: number;
|
||||
/** The new day string for care_streak_last_day */
|
||||
newLastDay: string;
|
||||
/** Description of what happened (for debugging/logging) */
|
||||
action: 'initialized' | 'incremented' | 'reset' | 'same_day';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag updates to apply to the Blobbi event.
|
||||
* Only present if wasUpdated is true.
|
||||
* Uses index signature for compatibility with updateBlobbiTags.
|
||||
*/
|
||||
export interface StreakTagUpdates {
|
||||
care_streak: string;
|
||||
care_streak_last_at: string;
|
||||
care_streak_last_day: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// ─── Core Logic ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate what the streak should be updated to based on current state and activity.
|
||||
*
|
||||
* This is a pure function that calculates the new streak state without side effects.
|
||||
* Use this to determine if/how the streak should be updated.
|
||||
*
|
||||
* @param currentStreak - Current streak value (0 or undefined means no streak yet)
|
||||
* @param lastDay - The last day string (YYYY-MM-DD) when streak was updated, or undefined
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns StreakUpdateResult describing the update
|
||||
*/
|
||||
export function calculateStreakUpdate(
|
||||
currentStreak: number | undefined,
|
||||
lastDay: string | undefined,
|
||||
now: Date = new Date()
|
||||
): StreakUpdateResult {
|
||||
const nowTimestamp = Math.floor(now.getTime() / 1000);
|
||||
const todayString = getLocalDayString(now);
|
||||
|
||||
// Case 1: No existing streak - initialize to 1
|
||||
if (currentStreak === undefined || currentStreak === 0 || !lastDay) {
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'initialized',
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Activity on the same day - no update needed
|
||||
if (lastDay === todayString) {
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: currentStreak,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate days since last activity
|
||||
const daysMissed = getDaysDifference(lastDay, todayString);
|
||||
|
||||
// Case 3: Next day (1 day difference) - increment streak
|
||||
if (daysMissed === 1) {
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: currentStreak + 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'incremented',
|
||||
};
|
||||
}
|
||||
|
||||
// Case 4: Missed 2+ days - reset to 1
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'reset',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tag updates to apply to a Blobbi event for a streak update.
|
||||
* Returns undefined if no update is needed (same day activity).
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns Tag updates to apply, or undefined if no update needed
|
||||
*/
|
||||
export function getStreakTagUpdates(
|
||||
companion: BlobbiCompanion,
|
||||
now: Date = new Date()
|
||||
): StreakTagUpdates | undefined {
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
if (!result.wasUpdated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
care_streak: result.newStreak.toString(),
|
||||
care_streak_last_at: result.newLastAt.toString(),
|
||||
care_streak_last_day: result.newLastDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a streak update is needed for the companion.
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns true if the streak should be updated
|
||||
*/
|
||||
export function needsStreakUpdate(
|
||||
companion: BlobbiCompanion,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
return result.wasUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current streak status for display purposes.
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @returns Object with streak info for UI display
|
||||
*/
|
||||
export function getStreakStatus(companion: BlobbiCompanion): {
|
||||
streak: number;
|
||||
lastDay: string | undefined;
|
||||
isActive: boolean;
|
||||
daysSinceLastActivity: number | undefined;
|
||||
} {
|
||||
const streak = companion.careStreak ?? 0;
|
||||
const lastDay = companion.careStreakLastDay;
|
||||
const today = getLocalDayString();
|
||||
|
||||
let daysSinceLastActivity: number | undefined;
|
||||
let isActive = false;
|
||||
|
||||
if (lastDay) {
|
||||
daysSinceLastActivity = getDaysDifference(lastDay, today);
|
||||
// Streak is "active" if we've had activity today or yesterday
|
||||
isActive = daysSinceLastActivity <= 1;
|
||||
}
|
||||
|
||||
return {
|
||||
streak,
|
||||
lastDay,
|
||||
isActive,
|
||||
daysSinceLastActivity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Daily Mission Tracker - Standalone progress tracking utility
|
||||
*
|
||||
* This module provides a simple way to track daily mission progress
|
||||
* without requiring React hooks or context. It directly manipulates
|
||||
* localStorage for immediate persistence.
|
||||
*
|
||||
* This approach allows action hooks (which may be called outside of
|
||||
* the daily missions hook context) to record progress.
|
||||
*/
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
updateMissionProgress,
|
||||
} from './daily-missions';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the current daily missions state from localStorage
|
||||
*/
|
||||
function readState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the daily missions state to localStorage
|
||||
*/
|
||||
function writeState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[DailyMissionTracker] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid state for today, creating one if necessary
|
||||
*/
|
||||
function ensureCurrentState(pubkey?: string): DailyMissionsState {
|
||||
const current = readState();
|
||||
|
||||
if (needsDailyReset(current)) {
|
||||
const previousCoins = current?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
|
||||
writeState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
return current!;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record progress for a daily mission action.
|
||||
* This function can be called from anywhere (hooks, event handlers, etc.)
|
||||
* and will immediately persist to localStorage.
|
||||
*
|
||||
* @param action - The action type that was performed
|
||||
* @param count - Number of times the action was performed (default: 1)
|
||||
* @param pubkey - Optional user pubkey for personalized mission selection
|
||||
*/
|
||||
export function trackDailyMissionProgress(
|
||||
action: DailyMissionAction,
|
||||
count: number = 1,
|
||||
pubkey?: string
|
||||
): void {
|
||||
const current = ensureCurrentState(pubkey);
|
||||
const updated = updateMissionProgress(current, action, count);
|
||||
writeState(updated);
|
||||
|
||||
// Dispatch a custom event so React components can re-render if needed
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to track multiple actions at once.
|
||||
* Useful when an action should count toward multiple missions.
|
||||
*
|
||||
* @param actions - Array of actions to track
|
||||
* @param pubkey - Optional user pubkey
|
||||
*/
|
||||
export function trackMultipleDailyMissionActions(
|
||||
actions: DailyMissionAction[],
|
||||
pubkey?: string
|
||||
): void {
|
||||
let current = ensureCurrentState(pubkey);
|
||||
|
||||
for (const action of actions) {
|
||||
current = updateMissionProgress(current, action, 1);
|
||||
}
|
||||
|
||||
writeState(current);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* Daily Missions System for Blobbi
|
||||
*
|
||||
* This module defines the daily mission pool, selection logic, and types.
|
||||
* Daily missions are separate from hatch/evolve missions and provide
|
||||
* daily engagement loops with coin rewards.
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mission action types that can trigger progress
|
||||
*/
|
||||
export type DailyMissionAction =
|
||||
| 'interact' // Any interaction (feed, clean, play, etc.)
|
||||
| 'feed' // Feeding action specifically
|
||||
| 'clean' // Cleaning action specifically
|
||||
| 'sing' // Sing direct action
|
||||
| 'play_music' // Play music direct action
|
||||
| 'sleep' // Put Blobbi to sleep
|
||||
| 'take_photo' // Take a photo of Blobbi
|
||||
| 'medicine'; // Give medicine to Blobbi
|
||||
|
||||
/**
|
||||
* Blobbi stage type for filtering missions
|
||||
*/
|
||||
export type BlobbiStage = 'egg' | 'baby' | 'adult';
|
||||
|
||||
/**
|
||||
* Definition of a daily mission in the pool
|
||||
*/
|
||||
export interface DailyMissionDefinition {
|
||||
/** Unique identifier for this mission type */
|
||||
id: string;
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Description of what to do */
|
||||
description: string;
|
||||
/** Action that triggers progress */
|
||||
action: DailyMissionAction;
|
||||
/** Number of times the action must be performed */
|
||||
requiredCount: number;
|
||||
/** Coin reward for completing this mission */
|
||||
reward: number;
|
||||
/** Selection weight (higher = more likely to be selected) */
|
||||
weight: number;
|
||||
/** Required stages to show this mission (if empty/undefined, requires baby or adult) */
|
||||
requiredStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A daily mission instance with progress tracking
|
||||
*/
|
||||
export interface DailyMission extends DailyMissionDefinition {
|
||||
/** Current progress (how many times the action has been performed today) */
|
||||
currentCount: number;
|
||||
/** Whether the mission has been completed */
|
||||
completed: boolean;
|
||||
/** Whether the reward has been claimed */
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored state for daily missions (persisted in localStorage)
|
||||
*/
|
||||
export interface DailyMissionsState {
|
||||
/** The date string (YYYY-MM-DD) when these missions were generated */
|
||||
date: string;
|
||||
/** The selected missions for this day */
|
||||
missions: DailyMission[];
|
||||
/** Total coins earned from daily missions (lifetime) */
|
||||
totalCoinsEarned: number;
|
||||
/** Whether the bonus mission has been claimed today */
|
||||
bonusClaimed?: boolean;
|
||||
/** Number of rerolls remaining for today (resets daily, max 3) */
|
||||
rerollsRemaining?: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum number of mission rerolls allowed per day */
|
||||
export const MAX_DAILY_REROLLS = 3;
|
||||
|
||||
// ─── Mission Pool ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pool of available daily missions.
|
||||
* Weights determine selection frequency:
|
||||
* - High weight (10): Common missions (interact, feed, clean)
|
||||
* - Medium weight (6): Regular missions (sing, play music, sleep)
|
||||
* - Low weight (2): Uncommon missions (change shape)
|
||||
* - Rare weight (1): Rare missions (take photo)
|
||||
*/
|
||||
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BABY/ADULT ONLY MISSIONS
|
||||
// These actions are NOT available for eggs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Interact Missions (Baby/Adult only) ───────────────────────────────────
|
||||
{
|
||||
id: 'interact_3',
|
||||
title: 'Quick Care',
|
||||
description: 'Interact with your Blobbi 3 times',
|
||||
action: 'interact',
|
||||
requiredCount: 3,
|
||||
reward: 30,
|
||||
weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'interact_6',
|
||||
title: 'Attentive Caretaker',
|
||||
description: 'Interact with your Blobbi 6 times',
|
||||
action: 'interact',
|
||||
requiredCount: 6,
|
||||
reward: 50,
|
||||
weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Feed Missions (Baby/Adult only) ───────────────────────────────────────
|
||||
{
|
||||
id: 'feed_1',
|
||||
title: 'Snack Time',
|
||||
description: 'Feed your Blobbi once',
|
||||
action: 'feed',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_2',
|
||||
title: 'Hungry Blobbi',
|
||||
description: 'Feed your Blobbi 2 times',
|
||||
action: 'feed',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_3',
|
||||
title: 'Feast Day',
|
||||
description: 'Feed your Blobbi 3 times',
|
||||
action: 'feed',
|
||||
requiredCount: 3,
|
||||
reward: 60,
|
||||
weight: 5,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Sleep Missions (Baby/Adult only) ──────────────────────────────────────
|
||||
{
|
||||
id: 'sleep_1',
|
||||
title: 'Nap Time',
|
||||
description: 'Put your Blobbi to sleep',
|
||||
action: 'sleep',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Photo Missions (Baby/Adult only) ──────────────────────────────────────
|
||||
{
|
||||
id: 'take_photo_1',
|
||||
title: 'Snapshot',
|
||||
description: 'Take a polaroid photo of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 1,
|
||||
reward: 55,
|
||||
weight: 4,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'take_photo_2',
|
||||
title: 'Photo Album',
|
||||
description: 'Take 2 photos of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
weight: 2,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EGG + BABY + ADULT MISSIONS
|
||||
// These actions are available for ALL stages including eggs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Clean Missions (All stages) ───────────────────────────────────────────
|
||||
{
|
||||
id: 'clean_1',
|
||||
title: 'Quick Cleanup',
|
||||
description: 'Clean your Blobbi once',
|
||||
action: 'clean',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
weight: 10,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'clean_2',
|
||||
title: 'Squeaky Clean',
|
||||
description: 'Clean your Blobbi 2 times',
|
||||
action: 'clean',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Sing Missions (All stages) ────────────────────────────────────────────
|
||||
{
|
||||
id: 'sing_1',
|
||||
title: 'Sing Along',
|
||||
description: 'Sing a song to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'sing_2',
|
||||
title: 'Karaoke Session',
|
||||
description: 'Sing 2 songs to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Play Music Missions (All stages) ──────────────────────────────────────
|
||||
{
|
||||
id: 'play_music_1',
|
||||
title: 'DJ Time',
|
||||
description: 'Play a song for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'play_music_2',
|
||||
title: 'Music Marathon',
|
||||
description: 'Play 2 songs for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Medicine Missions (All stages) ────────────────────────────────────────
|
||||
// Medicine rewards are higher since medicine costs coins to use
|
||||
{
|
||||
id: 'medicine_1',
|
||||
title: 'Health Check',
|
||||
description: 'Give medicine to your Blobbi',
|
||||
action: 'medicine',
|
||||
requiredCount: 1,
|
||||
reward: 60,
|
||||
weight: 5,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'medicine_2',
|
||||
title: 'Doctor Visit',
|
||||
description: 'Give medicine to your Blobbi 2 times',
|
||||
action: 'medicine',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the current date string in YYYY-MM-DD format (local timezone)
|
||||
*/
|
||||
export function getTodayDateString(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a seed number from a date string and optional user pubkey.
|
||||
* Used for deterministic daily mission selection.
|
||||
*/
|
||||
function generateDailySeed(dateString: string, pubkey?: string): number {
|
||||
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded random number generator (Mulberry32)
|
||||
*/
|
||||
function seededRandom(seed: number): () => number {
|
||||
return function() {
|
||||
let t = seed += 0x6D2B79F5;
|
||||
t = Math.imul(t ^ t >>> 15, t | 1);
|
||||
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mission is available for the given stages.
|
||||
* Missions with no requiredStages default to requiring baby or adult.
|
||||
*/
|
||||
function isMissionAvailableForStages(
|
||||
mission: DailyMissionDefinition,
|
||||
availableStages: BlobbiStage[]
|
||||
): boolean {
|
||||
const requiredStages = mission.requiredStages ?? ['baby', 'adult'];
|
||||
return requiredStages.some((stage) => availableStages.includes(stage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select N missions from the pool using weighted random selection.
|
||||
* Uses a seeded random generator for deterministic daily selection.
|
||||
*
|
||||
* @param count - Number of missions to select
|
||||
* @param dateString - Date string for seeding (YYYY-MM-DD)
|
||||
* @param pubkey - Optional user pubkey for seeding
|
||||
* @param availableStages - Stages the user has available (filters eligible missions)
|
||||
*/
|
||||
export function selectDailyMissions(
|
||||
count: number,
|
||||
dateString: string,
|
||||
pubkey?: string,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionDefinition[] {
|
||||
const seed = generateDailySeed(dateString, pubkey);
|
||||
const random = seededRandom(seed);
|
||||
|
||||
// Filter pool by available stages (default to baby/adult if not specified)
|
||||
const stagesToCheck = availableStages ?? ['baby', 'adult'];
|
||||
const eligibleMissions = DAILY_MISSION_POOL.filter((m) =>
|
||||
isMissionAvailableForStages(m, stagesToCheck)
|
||||
);
|
||||
|
||||
// If no missions are available for the user's stages, return empty
|
||||
if (eligibleMissions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create a copy of the eligible pool
|
||||
const available = [...eligibleMissions];
|
||||
const selected: DailyMissionDefinition[] = [];
|
||||
|
||||
while (selected.length < count && available.length > 0) {
|
||||
// Calculate total weight of remaining missions
|
||||
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
|
||||
|
||||
// Pick a random value in [0, totalWeight)
|
||||
let pick = random() * totalWeight;
|
||||
|
||||
// Find the mission that corresponds to this pick
|
||||
let selectedIndex = 0;
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
pick -= available[i].weight;
|
||||
if (pick <= 0) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to selected and remove from available
|
||||
selected.push(available[selectedIndex]);
|
||||
available.splice(selectedIndex, 1);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh DailyMission from a definition
|
||||
*/
|
||||
export function createMissionFromDefinition(def: DailyMissionDefinition): DailyMission {
|
||||
return {
|
||||
...def,
|
||||
currentCount: 0,
|
||||
completed: false,
|
||||
claimed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial daily missions state for a new day
|
||||
*/
|
||||
export function createDailyMissionsState(
|
||||
dateString: string,
|
||||
pubkey?: string,
|
||||
previousTotalCoins: number = 0,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionsState {
|
||||
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
|
||||
return {
|
||||
date: dateString,
|
||||
missions: definitions.map(createMissionFromDefinition),
|
||||
totalCoinsEarned: previousTotalCoins,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the daily missions need to be reset (new day)
|
||||
*/
|
||||
export function needsDailyReset(state: DailyMissionsState | null): boolean {
|
||||
if (!state) return true;
|
||||
return state.date !== getTodayDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mission progress for a given action
|
||||
*/
|
||||
export function updateMissionProgress(
|
||||
state: DailyMissionsState,
|
||||
action: DailyMissionAction,
|
||||
incrementBy: number = 1
|
||||
): DailyMissionsState {
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
// Skip if not the matching action or already completed
|
||||
if (mission.action !== action || mission.completed) {
|
||||
return mission;
|
||||
}
|
||||
|
||||
const newCount = Math.min(mission.currentCount + incrementBy, mission.requiredCount);
|
||||
const nowCompleted = newCount >= mission.requiredCount;
|
||||
|
||||
return {
|
||||
...mission,
|
||||
currentCount: newCount,
|
||||
completed: nowCompleted,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim reward for a completed mission
|
||||
*/
|
||||
export function claimMissionReward(
|
||||
state: DailyMissionsState,
|
||||
missionId: string
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
let coinsEarned = 0;
|
||||
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
if (mission.id !== missionId) return mission;
|
||||
|
||||
// Can only claim if completed and not yet claimed
|
||||
if (!mission.completed || mission.claimed) return mission;
|
||||
|
||||
coinsEarned = mission.reward;
|
||||
return {
|
||||
...mission,
|
||||
claimed: true,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
|
||||
},
|
||||
coinsEarned,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total potential reward for all daily missions
|
||||
*/
|
||||
export function getTotalPotentialReward(state: DailyMissionsState): number {
|
||||
return state.missions.reduce((sum, m) => sum + m.reward, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total claimed reward for today
|
||||
*/
|
||||
export function getTodayClaimedReward(state: DailyMissionsState): number {
|
||||
return state.missions
|
||||
.filter((m) => m.claimed)
|
||||
.reduce((sum, m) => sum + m.reward, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all daily missions are completed
|
||||
*/
|
||||
export function areAllMissionsCompleted(state: DailyMissionsState): boolean {
|
||||
return state.missions.every((m) => m.completed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all daily missions are claimed
|
||||
*/
|
||||
export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
|
||||
return state.missions.every((m) => m.claimed);
|
||||
}
|
||||
|
||||
// ─── Bonus Mission ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The bonus mission that becomes available after completing all regular missions.
|
||||
* This is a special mission that rewards extra coins for daily completion.
|
||||
*/
|
||||
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
|
||||
id: 'bonus_daily_complete',
|
||||
title: 'Daily Champion',
|
||||
description: 'Complete all daily missions to claim this bonus reward',
|
||||
action: 'interact', // Not actually used - bonus is auto-completed
|
||||
requiredCount: 1,
|
||||
reward: 80,
|
||||
weight: 0, // Not part of random selection
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the bonus mission is available (all regular missions completed)
|
||||
*/
|
||||
export function isBonusMissionAvailable(state: DailyMissionsState): boolean {
|
||||
// Bonus is available if there are regular missions and all are completed
|
||||
return state.missions.length > 0 && areAllMissionsCompleted(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the bonus mission has been claimed today
|
||||
*/
|
||||
export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
|
||||
return state.bonusClaimed ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the bonus mission reward
|
||||
*/
|
||||
export function claimBonusMissionReward(
|
||||
state: DailyMissionsState
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
// Can only claim if bonus is available and not yet claimed
|
||||
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
|
||||
return { state, coinsEarned: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
|
||||
},
|
||||
coinsEarned: BONUS_MISSION_DEFINITION.reward,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Mission Reroll ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the number of rerolls remaining for today.
|
||||
* Returns MAX_DAILY_REROLLS if not set (for backward compatibility with old state).
|
||||
*/
|
||||
export function getRerollsRemaining(state: DailyMissionsState): number {
|
||||
// If rerollsRemaining is not set (old state), default to max
|
||||
if (state.rerollsRemaining === undefined || state.rerollsRemaining === null) {
|
||||
return MAX_DAILY_REROLLS;
|
||||
}
|
||||
return state.rerollsRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user can reroll a mission
|
||||
*/
|
||||
export function canRerollMission(state: DailyMissionsState, missionId: string): boolean {
|
||||
const rerollsRemaining = getRerollsRemaining(state);
|
||||
if (rerollsRemaining <= 0) return false;
|
||||
|
||||
// Find the mission
|
||||
const mission = state.missions.find((m) => m.id === missionId);
|
||||
if (!mission) return false;
|
||||
|
||||
// Cannot reroll completed or claimed missions
|
||||
if (mission.completed || mission.claimed) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a replacement mission that:
|
||||
* - Is not already in the current mission list
|
||||
* - Is not the mission being replaced (avoid immediately giving back the same)
|
||||
* - Respects the user's available stages
|
||||
*
|
||||
* Uses weighted random selection from eligible missions.
|
||||
*/
|
||||
export function selectReplacementMission(
|
||||
currentMissions: DailyMission[],
|
||||
missionToReplace: DailyMission,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionDefinition | null {
|
||||
// Default to baby/adult if no stages provided (most common case)
|
||||
const stagesToCheck = availableStages && availableStages.length > 0
|
||||
? availableStages
|
||||
: ['baby', 'adult'] as BlobbiStage[];
|
||||
|
||||
// Get IDs of missions that cannot be selected (current active missions)
|
||||
const excludedIds = new Set<string>();
|
||||
|
||||
// Exclude all current missions EXCEPT the one being replaced
|
||||
for (const m of currentMissions) {
|
||||
if (m.id !== missionToReplace.id) {
|
||||
excludedIds.add(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter pool to eligible missions
|
||||
const eligibleMissions = DAILY_MISSION_POOL.filter((m) => {
|
||||
// Must not be an already-active mission (except the one being replaced)
|
||||
if (excludedIds.has(m.id)) return false;
|
||||
// Must not be the same mission being replaced
|
||||
if (m.id === missionToReplace.id) return false;
|
||||
// Must be available for user's stages
|
||||
if (!isMissionAvailableForStages(m, stagesToCheck)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no eligible missions, return null
|
||||
if (eligibleMissions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use Math.random() for non-deterministic selection (rerolls should feel random)
|
||||
const totalWeight = eligibleMissions.reduce((sum, m) => sum + m.weight, 0);
|
||||
let pick = Math.random() * totalWeight;
|
||||
|
||||
for (const mission of eligibleMissions) {
|
||||
pick -= mission.weight;
|
||||
if (pick <= 0) {
|
||||
return mission;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first eligible (shouldn't happen)
|
||||
return eligibleMissions[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reroll a mission, replacing it with a new one from the pool.
|
||||
* Returns the updated state and the new mission, or null if reroll failed.
|
||||
*/
|
||||
export function rerollMission(
|
||||
state: DailyMissionsState,
|
||||
missionId: string,
|
||||
availableStages?: BlobbiStage[]
|
||||
): { state: DailyMissionsState; newMission: DailyMission } | null {
|
||||
// Check if reroll is allowed
|
||||
if (!canRerollMission(state, missionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the mission index
|
||||
const missionIndex = state.missions.findIndex((m) => m.id === missionId);
|
||||
if (missionIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldMission = state.missions[missionIndex];
|
||||
|
||||
// Select a replacement
|
||||
const replacement = selectReplacementMission(state.missions, oldMission, availableStages);
|
||||
if (!replacement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create the new mission instance
|
||||
const newMission = createMissionFromDefinition(replacement);
|
||||
|
||||
// Update the missions array
|
||||
const updatedMissions = [...state.missions];
|
||||
updatedMissions[missionIndex] = newMission;
|
||||
|
||||
// Decrement rerolls remaining
|
||||
const newRerollsRemaining = getRerollsRemaining(state) - 1;
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
rerollsRemaining: newRerollsRemaining,
|
||||
},
|
||||
newMission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Grupo das pétalas com rotação -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="10s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
|
||||
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
|
||||
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
|
||||
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
|
||||
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
|
||||
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
|
||||
</g>
|
||||
|
||||
<!-- Grupo das partículas giratórias -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="20s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.8" />
|
||||
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.6" />
|
||||
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.7" />
|
||||
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.5" />
|
||||
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.9" />
|
||||
</g>
|
||||
|
||||
<!-- Centro da flor -->
|
||||
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
|
||||
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<circle cx="88" cy="105" r="8" fill="white" />
|
||||
<circle cx="112" cy="105" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="88" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="112" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="90" cy="103" r="2" fill="white" />
|
||||
<circle cx="114" cy="103" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 90 120 Q 100 128 110 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Bochechas -->
|
||||
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fed7d7" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#e0e7ff" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dcfce7" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dbeafe" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,99 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Grupo das pétalas com rotação mais lenta (ou pode ser removido completamente) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="20s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
|
||||
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
|
||||
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
|
||||
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
|
||||
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
|
||||
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
|
||||
</g>
|
||||
|
||||
<!-- Grupo das partículas giratórias -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="30s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.4" />
|
||||
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.3" />
|
||||
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.3" />
|
||||
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.2" />
|
||||
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.4" />
|
||||
</g>
|
||||
|
||||
<!-- Centro da flor -->
|
||||
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
|
||||
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Olhos dormindo -->
|
||||
<path d="M 80 105 Q 88 108 96 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 104 105 Q 112 108 120 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Boca calma -->
|
||||
<circle cx="100" cy="120" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Bochechas -->
|
||||
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
|
||||
<!-- "Zzz" dormindo -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fed7d7" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#e0e7ff" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dcfce7" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dbeafe" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,100 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main leaf body - classic leaf shape -->
|
||||
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
|
||||
fill="url(#breezyBody)" />
|
||||
|
||||
<!-- Leaf veins - central vein -->
|
||||
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
|
||||
|
||||
<!-- Side veins -->
|
||||
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Inner leaf highlight -->
|
||||
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
|
||||
fill="url(#breezyInner)" opacity="0.6" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="85" cy="90" r="10" fill="white" />
|
||||
<circle cx="115" cy="90" r="10" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="90" r="6" fill="#1f2937" />
|
||||
<circle cx="115" cy="90" r="6" fill="#1f2937" />
|
||||
<circle cx="87" cy="88" r="3" fill="white" />
|
||||
<circle cx="117" cy="88" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 85 110 Q 100 120 115 110" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
|
||||
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
|
||||
<!-- Floating leaves with rotation groups -->
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
|
||||
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.8" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
|
||||
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.6" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
|
||||
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.7" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
|
||||
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="30%" stop-color="#4ade80" />
|
||||
<stop offset="70%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#bbf7d0" />
|
||||
<stop offset="100%" stop-color="#86efac" />
|
||||
</radialGradient>
|
||||
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#15803d" />
|
||||
<stop offset="50%" stop-color="#16a34a" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</linearGradient>
|
||||
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,95 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main leaf body -->
|
||||
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
|
||||
fill="url(#breezyBody)" />
|
||||
|
||||
<!-- Leaf veins -->
|
||||
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
|
||||
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Inner leaf highlight -->
|
||||
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
|
||||
fill="url(#breezyInner)" opacity="0.6" />
|
||||
|
||||
<!-- Olhos dormindo -->
|
||||
<path d="M 75 90 Q 85 93 95 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 105 90 Q 115 93 125 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Boca tranquila -->
|
||||
<circle cx="100" cy="110" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
|
||||
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
|
||||
<!-- Floating leaves -->
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
|
||||
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.6" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
|
||||
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
|
||||
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
|
||||
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.4" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" dormindo -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="30%" stop-color="#4ade80" />
|
||||
<stop offset="70%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#bbf7d0" />
|
||||
<stop offset="100%" stop-color="#86efac" />
|
||||
</radialGradient>
|
||||
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#15803d" />
|
||||
<stop offset="50%" stop-color="#16a34a" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</linearGradient>
|
||||
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,75 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<!-- Main cactus body -->
|
||||
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" />
|
||||
|
||||
<!-- Cactus arms -->
|
||||
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
|
||||
<rect x="120" y="110" width="20" height="35" rx="10" fill="url(#cactiArm)" />
|
||||
|
||||
<!-- Cactus ridges -->
|
||||
<line x1="92" y1="85" x2="92" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
<line x1="100" y1="85" x2="100" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
<line x1="108" y1="85" x2="108" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="90" cy="105" r="8" fill="white" />
|
||||
<circle cx="110" cy="105" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="90" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="110" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="92" cy="103" r="2" fill="white" />
|
||||
<circle cx="112" cy="103" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 92 120 Q 100 126 108 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Tiny spines -->
|
||||
<circle cx="88" cy="90" r="1" fill="#65a30d" />
|
||||
<circle cx="95" cy="95" r="1" fill="#65a30d" />
|
||||
<circle cx="105" cy="92" r="1" fill="#65a30d" />
|
||||
<circle cx="112" cy="88" r="1" fill="#65a30d" />
|
||||
<circle cx="65" cy="110" r="1" fill="#65a30d" />
|
||||
<circle cx="70" cy="120" r="1" fill="#65a30d" />
|
||||
<circle cx="125" cy="115" r="1" fill="#65a30d" />
|
||||
<circle cx="130" cy="125" r="1" fill="#65a30d" />
|
||||
|
||||
<!-- Little legs in pot -->
|
||||
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#cactiPot)" />
|
||||
<rect x="75" y="160" width="50" height="5" fill="url(#cactiPotRim)" rx="2" />
|
||||
|
||||
<!-- Blooming flower -->
|
||||
<circle cx="100" cy="75" r="12" fill="url(#cactiFlower)" />
|
||||
<circle cx="100" cy="75" r="8" fill="url(#cactiFlowerCenter)" />
|
||||
<circle cx="100" cy="75" r="4" fill="#fbbf24" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="cactiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a3e635" />
|
||||
<stop offset="30%" stop-color="#84cc16" />
|
||||
<stop offset="70%" stop-color="#65a30d" />
|
||||
<stop offset="100%" stop-color="#4d7c0f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#84cc16" />
|
||||
<stop offset="100%" stop-color="#65a30d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiPot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiPotRim" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiFlower" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiFlowerCenter" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<!-- Main cactus body -->
|
||||
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" />
|
||||
|
||||
<!-- Cactus arms -->
|
||||
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
|
||||
<rect x="120" y="110" width="20" height="35" rx="10" fill="url(#cactiArm)" />
|
||||
|
||||
<!-- Cactus ridges -->
|
||||
<line x1="92" y1="85" x2="92" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
<line x1="100" y1="85" x2="100" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
<line x1="108" y1="85" x2="108" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 82 105 Q 90 108 98 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 105 Q 110 108 118 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="120" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Tiny spines -->
|
||||
<circle cx="88" cy="90" r="1" fill="#65a30d" />
|
||||
<circle cx="95" cy="95" r="1" fill="#65a30d" />
|
||||
<circle cx="105" cy="92" r="1" fill="#65a30d" />
|
||||
<circle cx="112" cy="88" r="1" fill="#65a30d" />
|
||||
<circle cx="65" cy="110" r="1" fill="#65a30d" />
|
||||
<circle cx="70" cy="120" r="1" fill="#65a30d" />
|
||||
<circle cx="125" cy="115" r="1" fill="#65a30d" />
|
||||
<circle cx="130" cy="125" r="1" fill="#65a30d" />
|
||||
|
||||
<!-- Little legs in pot -->
|
||||
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#cactiPot)" />
|
||||
<rect x="75" y="160" width="50" height="5" fill="url(#cactiPotRim)" rx="2" />
|
||||
|
||||
<!-- Blooming flower -->
|
||||
<circle cx="100" cy="75" r="12" fill="url(#cactiFlower)" />
|
||||
<circle cx="100" cy="75" r="8" fill="url(#cactiFlowerCenter)" />
|
||||
<circle cx="100" cy="75" r="4" fill="#fbbf24" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="cactiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a3e635" />
|
||||
<stop offset="30%" stop-color="#84cc16" />
|
||||
<stop offset="70%" stop-color="#65a30d" />
|
||||
<stop offset="100%" stop-color="#4d7c0f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#84cc16" />
|
||||
<stop offset="100%" stop-color="#65a30d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiPot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiPotRim" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiFlower" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cactiFlowerCenter" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="cattiBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEar3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEarInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f3f4f6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiNose3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#be185d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiNoseHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fce7f3;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiTailHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="cattiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Oval upright body -->
|
||||
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
|
||||
|
||||
<!-- Triangle ears -->
|
||||
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
|
||||
<path d="M 132 72 L 142 48 L 118 62 Z" fill="url(#cattiEar3D)" />
|
||||
<path d="M 70 62 L 64 52 L 76 58 Z" fill="url(#cattiEarInner)" />
|
||||
<path d="M 130 62 L 136 52 L 124 58 Z" fill="url(#cattiEarInner)" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<ellipse cx="85" cy="100" rx="12" ry="16" fill="url(#cattiEyeWhite3D)" />
|
||||
<ellipse cx="115" cy="100" rx="12" ry="16" fill="url(#cattiEyeWhite3D)" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<ellipse cx="85" cy="100" rx="8" ry="12" fill="url(#cattiPupil3D)" />
|
||||
<ellipse cx="115" cy="100" rx="8" ry="12" fill="url(#cattiPupil3D)" />
|
||||
<ellipse cx="87" cy="97" rx="3" ry="4" fill="white" opacity="0.9" />
|
||||
<ellipse cx="117" cy="97" rx="3" ry="4" fill="white" opacity="0.9" />
|
||||
|
||||
<!-- Nose -->
|
||||
<path d="M 94 115 L 100 122 L 106 115 Z" fill="url(#cattiNose3D)" />
|
||||
<path d="M 96 116 L 100 120 L 104 116 Z" fill="url(#cattiNoseHighlight)" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 100 122 Q 88 128 82 122" stroke="url(#cattiMouth3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 100 122 Q 112 128 118 122" stroke="url(#cattiMouth3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced curved tail -->
|
||||
<path d="M 145 140 Q 165 115 158 95 Q 148 75 165 65" stroke="url(#cattiTail3D)" stroke-width="22" fill="none" stroke-linecap="round" />
|
||||
<path d="M 145 140 Q 163 117 156 97 Q 148 79 163 69" stroke="url(#cattiTailHighlight)" stroke-width="16" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced whiskers -->
|
||||
<path d="M 48 108 Q 58 110 72 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 48 118 Q 58 120 72 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 128 108 Q 138 110 152 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 128 118 Q 138 120 152 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft fur texture details -->
|
||||
<ellipse cx="75" cy="135" rx="3" ry="2" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="125" cy="130" rx="2.5" ry="2" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="90" cy="150" rx="2" ry="1.5" fill="rgba(255,255,255,0.15)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="cattiBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEar3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEarInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f3f4f6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiNose3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#be185d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiNoseHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fce7f3;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f97316;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cattiTailHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fed7aa;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="cattiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Oval upright body -->
|
||||
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
|
||||
|
||||
<!-- Triangle ears -->
|
||||
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
|
||||
<path d="M 132 72 L 142 48 L 118 62 Z" fill="url(#cattiEar3D)" />
|
||||
<path d="M 70 62 L 64 52 L 76 58 Z" fill="url(#cattiEarInner)" />
|
||||
<path d="M 130 62 L 136 52 L 124 58 Z" fill="url(#cattiEarInner)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 73 100 Q 85 103 97 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 103 100 Q 115 103 127 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced cat nose -->
|
||||
<path d="M 94 115 L 100 122 L 106 115 Z" fill="url(#cattiNose3D)" />
|
||||
<path d="M 96 116 L 100 120 L 104 116 Z" fill="url(#cattiNoseHighlight)" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="125" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Enhanced curved tail -->
|
||||
<path d="M 145 140 Q 165 115 158 95 Q 148 75 165 65" stroke="url(#cattiTail3D)" stroke-width="22" fill="none" stroke-linecap="round" />
|
||||
<path d="M 145 140 Q 163 117 156 97 Q 148 79 163 69" stroke="url(#cattiTailHighlight)" stroke-width="16" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced whiskers -->
|
||||
<path d="M 48 108 Q 58 110 72 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 48 118 Q 58 120 72 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 128 108 Q 138 110 152 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
<path d="M 128 118 Q 138 120 152 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft fur texture details -->
|
||||
<ellipse cx="75" cy="135" rx="3" ry="2" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="125" cy="130" rx="2.5" ry="2" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="90" cy="150" rx="2" ry="1.5" fill="rgba(255,255,255,0.15)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main cloud body - multiple overlapping circles -->
|
||||
<circle cx="100" cy="120" r="45" fill="url(#cloudiBody)" />
|
||||
<circle cx="75" cy="110" r="35" fill="url(#cloudiBody)" />
|
||||
<circle cx="125" cy="110" r="35" fill="url(#cloudiBody)" />
|
||||
<circle cx="85" cy="95" r="25" fill="url(#cloudiBody)" />
|
||||
<circle cx="115" cy="95" r="25" fill="url(#cloudiBody)" />
|
||||
<circle cx="100" cy="85" r="30" fill="url(#cloudiBody)" />
|
||||
|
||||
<!-- Fluffy highlights -->
|
||||
<circle cx="90" cy="100" r="20" fill="url(#cloudiHighlight)" opacity="0.6" />
|
||||
<circle cx="110" cy="105" r="18" fill="url(#cloudiHighlight)" opacity="0.5" />
|
||||
<circle cx="100" cy="90" r="15" fill="url(#cloudiHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<circle cx="88" cy="100" r="8" fill="white" />
|
||||
<circle cx="112" cy="100" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="88" cy="100" r="5" fill="#64748b" />
|
||||
<circle cx="112" cy="100" r="5" fill="#64748b" />
|
||||
<circle cx="90" cy="98" r="2" fill="white" />
|
||||
<circle cx="114" cy="98" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 92 115 Q 100 122 108 115" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Floating raindrops -->
|
||||
<circle cx="70" cy="140" r="3" fill="url(#cloudiRain)" opacity="0.8" />
|
||||
<circle cx="130" cy="145" r="2.5" fill="url(#cloudiRain)" opacity="0.6" />
|
||||
<circle cx="85" cy="155" r="2" fill="url(#cloudiRain)" opacity="0.7" />
|
||||
<circle cx="115" cy="150" r="2.5" fill="url(#cloudiRain)" opacity="0.5" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="50%" stop-color="#f1f5f9" />
|
||||
<stop offset="100%" stop-color="#e2e8f0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.5)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cloudiRain" cx="0.5" cy="0.3">
|
||||
<stop offset="0%" stop-color="#60a5fa" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,51 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main cloud body - multiple overlapping circles -->
|
||||
<circle cx="100" cy="120" r="45" fill="url(#cloudiBody)" />
|
||||
<circle cx="75" cy="110" r="35" fill="url(#cloudiBody)" />
|
||||
<circle cx="125" cy="110" r="35" fill="url(#cloudiBody)" />
|
||||
<circle cx="85" cy="95" r="25" fill="url(#cloudiBody)" />
|
||||
<circle cx="115" cy="95" r="25" fill="url(#cloudiBody)" />
|
||||
<circle cx="100" cy="85" r="30" fill="url(#cloudiBody)" />
|
||||
|
||||
<!-- Fluffy highlights -->
|
||||
<circle cx="90" cy="100" r="20" fill="url(#cloudiHighlight)" opacity="0.6" />
|
||||
<circle cx="110" cy="105" r="18" fill="url(#cloudiHighlight)" opacity="0.5" />
|
||||
<circle cx="100" cy="90" r="15" fill="url(#cloudiHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 80 100 Q 88 103 96 100" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 104 100 Q 112 103 120 100" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#64748b" />
|
||||
|
||||
<!-- Floating raindrops with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 0,5; 0,0" dur="3s" repeatCount="indefinite" />
|
||||
<circle cx="70" cy="140" r="3" fill="url(#cloudiRain)" opacity="0.6" />
|
||||
<circle cx="130" cy="145" r="2.5" fill="url(#cloudiRain)" opacity="0.4" />
|
||||
<circle cx="85" cy="155" r="2" fill="url(#cloudiRain)" opacity="0.5" />
|
||||
<circle cx="115" cy="150" r="2.5" fill="url(#cloudiRain)" opacity="0.3" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="50%" stop-color="#f1f5f9" />
|
||||
<stop offset="100%" stop-color="#e2e8f0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.5)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="cloudiRain" cx="0.5" cy="0.3">
|
||||
<stop offset="0%" stop-color="#60a5fa" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Crystal gradients -->
|
||||
<radialGradient id="crystiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#e9d5ff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="crystiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="crystiFacet1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet2" x1="1" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet3" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet4" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet5" x1="1" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet6" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fb7185;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e11d48;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="crystiEye" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="crystiSmile" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main crystal body - rounded hexagon shape -->
|
||||
<path d="M 100 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" />
|
||||
<path d="M 100 55 L 135 82 L 135 128 L 100 155 L 65 128 L 65 82 Z" fill="url(#crystiInner)" opacity="0.7" />
|
||||
|
||||
<!-- Crystal segments with rounded edges -->
|
||||
<path d="M 100 50 L 125 70 L 100 105 L 75 70 Z" fill="url(#crystiFacet1)" opacity="0.8" />
|
||||
<path d="M 75 70 L 100 105 L 60 80 L 60 105 Z" fill="url(#crystiFacet2)" opacity="0.7" />
|
||||
<path d="M 125 70 L 140 80 L 140 105 L 100 105 Z" fill="url(#crystiFacet3)" opacity="0.7" />
|
||||
<path d="M 60 105 L 100 105 L 75 140 L 60 130 Z" fill="url(#crystiFacet4)" opacity="0.6" />
|
||||
<path d="M 100 105 L 140 105 L 125 140 L 100 105 Z" fill="url(#crystiFacet5)" opacity="0.6" />
|
||||
<path d="M 75 140 L 100 105 L 125 140 L 100 160 Z" fill="url(#crystiFacet6)" opacity="0.8" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="88" cy="95" r="10" fill="url(#crystiEye)" />
|
||||
<circle cx="112" cy="95" r="10" fill="url(#crystiEye)" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="88" cy="95" r="6" fill="#1e1b4b" />
|
||||
<circle cx="112" cy="95" r="6" fill="#1e1b4b" />
|
||||
<circle cx="90" cy="93" r="3" fill="white" />
|
||||
<circle cx="114" cy="93" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 90 115 Q 100 123 110 115" stroke="url(#crystiSmile)" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Floating sparkles -->
|
||||
<circle cx="65" cy="65" r="2" fill="#fbbf24" opacity="0.9" />
|
||||
<circle cx="135" cy="70" r="1.5" fill="#f472b6" opacity="0.8" />
|
||||
<circle cx="70" cy="140" r="1" fill="#06b6d4" opacity="0.7" />
|
||||
<circle cx="130" cy="135" r="2" fill="#fbbf24" opacity="0.6" />
|
||||
<circle cx="50" cy="105" r="1.5" fill="#f472b6" opacity="0.8" />
|
||||
<circle cx="150" cy="110" r="1" fill="#06b6d4" opacity="0.9" />
|
||||
<circle cx="100" cy="40" r="1.5" fill="#fbbf24" opacity="0.7" />
|
||||
<circle cx="100" cy="170" r="1" fill="#f472b6" opacity="0.8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Crystal gradients -->
|
||||
<radialGradient id="crystiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#e9d5ff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="crystiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="crystiFacet1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet2" x1="1" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet3" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet4" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet5" x1="1" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystiFacet6" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fb7185;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e11d48;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="crystiEye" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="crystiSmile" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main crystal body - rounded hexagon shape -->
|
||||
<path d="M 100 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" />
|
||||
<path d="M 100 55 L 135 82 L 135 128 L 100 155 L 65 128 L 65 82 Z" fill="url(#crystiInner)" opacity="0.7" />
|
||||
|
||||
<!-- Crystal segments with rounded edges -->
|
||||
<path d="M 100 50 L 125 70 L 100 105 L 75 70 Z" fill="url(#crystiFacet1)" opacity="0.6" />
|
||||
<path d="M 75 70 L 100 105 L 60 80 L 60 105 Z" fill="url(#crystiFacet2)" opacity="0.5" />
|
||||
<path d="M 125 70 L 140 80 L 140 105 L 100 105 Z" fill="url(#crystiFacet3)" opacity="0.5" />
|
||||
<path d="M 60 105 L 100 105 L 75 140 L 60 130 Z" fill="url(#crystiFacet4)" opacity="0.4" />
|
||||
<path d="M 100 105 L 140 105 L 125 140 L 100 105 Z" fill="url(#crystiFacet5)" opacity="0.4" />
|
||||
<path d="M 75 140 L 100 105 L 125 140 L 100 160 Z" fill="url(#crystiFacet6)" opacity="0.6" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 95 Q 112 98 122 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#8b5cf6" />
|
||||
|
||||
<!-- Floating sparkles with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="360 100 100" dur="15s" repeatCount="indefinite" />
|
||||
<circle cx="65" cy="65" r="2" fill="#fbbf24" opacity="0.6" />
|
||||
<circle cx="135" cy="70" r="1.5" fill="#f472b6" opacity="0.5" />
|
||||
<circle cx="70" cy="140" r="1" fill="#06b6d4" opacity="0.4" />
|
||||
<circle cx="130" cy="135" r="2" fill="#fbbf24" opacity="0.3" />
|
||||
</g>
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="-360 100 100" dur="20s" repeatCount="indefinite" />
|
||||
<circle cx="50" cy="105" r="1.5" fill="#f472b6" opacity="0.5" />
|
||||
<circle cx="150" cy="110" r="1" fill="#06b6d4" opacity="0.6" />
|
||||
<circle cx="100" cy="40" r="1.5" fill="#fbbf24" opacity="0.4" />
|
||||
<circle cx="100" cy="170" r="1" fill="#f472b6" opacity="0.5" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,89 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Water gradients -->
|
||||
<radialGradient id="droppiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#67e8f9;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#22d3ee;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#e0f2fe;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7dd3fc;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiHighlight" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiDroplet" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#7dd3fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main water drop body -->
|
||||
<path d="M 100 40 Q 100 30 100 40 Q 135 60 140 110 Q 140 150 100 165 Q 60 150 60 110 Q 65 60 100 40"
|
||||
fill="url(#droppiBody)" />
|
||||
|
||||
<!-- Inner water reflection -->
|
||||
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
|
||||
<ellipse cx="90" cy="80" rx="20" ry="25" fill="url(#droppiHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="85" cy="95" r="12" fill="white" />
|
||||
<circle cx="115" cy="95" r="12" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="95" r="8" fill="#0891b2" />
|
||||
<circle cx="115" cy="95" r="8" fill="#0891b2" />
|
||||
<circle cx="88" cy="92" r="4" fill="white" />
|
||||
<circle cx="118" cy="92" r="4" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 115 Q 100 123 112 115" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<ellipse cx="60" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(-25 60 110)" />
|
||||
<ellipse cx="140" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(25 140 110)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="85" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
|
||||
<ellipse cx="115" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
|
||||
|
||||
<!-- Water droplets floating around - grouped with rotation -->
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
|
||||
<circle cx="-45" cy="-35" r="3" fill="url(#droppiDroplet)" opacity="0.8" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
|
||||
<circle cx="45" cy="-30" r="2.5" fill="url(#droppiDroplet)" opacity="0.6" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
|
||||
<circle cx="-50" cy="25" r="2" fill="url(#droppiDroplet)" opacity="0.7" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
|
||||
<circle cx="50" cy="20" r="2.5" fill="url(#droppiDroplet)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,88 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Water gradients -->
|
||||
<radialGradient id="droppiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#67e8f9;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#22d3ee;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#e0f2fe;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7dd3fc;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiHighlight" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="droppiDroplet" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#7dd3fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main water drop body -->
|
||||
<path d="M 100 40 Q 100 30 100 40 Q 135 60 140 110 Q 140 150 100 165 Q 60 150 60 110 Q 65 60 100 40"
|
||||
fill="url(#droppiBody)" />
|
||||
|
||||
<!-- Inner water reflection -->
|
||||
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
|
||||
<ellipse cx="90" cy="80" rx="20" ry="25" fill="url(#droppiHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 73 95 Q 85 98 97 95" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 103 95 Q 115 98 127 95" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#0891b2" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<ellipse cx="60" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(-25 60 110)" />
|
||||
<ellipse cx="140" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(25 140 110)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="85" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
|
||||
<ellipse cx="115" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
|
||||
|
||||
<!-- Water droplets floating around - grouped with slower rotation -->
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="12s" repeatCount="indefinite" />
|
||||
<circle cx="-45" cy="-35" r="3" fill="url(#droppiDroplet)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="15s" repeatCount="indefinite" />
|
||||
<circle cx="45" cy="-30" r="2.5" fill="url(#droppiDroplet)" opacity="0.4" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="10s" repeatCount="indefinite" />
|
||||
<circle cx="-50" cy="25" r="2" fill="url(#droppiDroplet)" opacity="0.4" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 110)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="18s" repeatCount="indefinite" />
|
||||
<circle cx="50" cy="20" r="2.5" fill="url(#droppiDroplet)" opacity="0.3" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,76 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<!-- Flame gradients -->
|
||||
<radialGradient id="flammiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fbbf24" />
|
||||
<stop offset="30%" stop-color="#f97316" />
|
||||
<stop offset="70%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fde047" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiCore" cx="0.5" cy="0.4">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fde047" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f97316" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#b91c1c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiEmber" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fde047" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Larger rotating flames -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M45 80 Q50 65 55 80 Q50 90 45 80 Z" fill="url(#flammiEmber)" opacity="0.8" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="4s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M155 85 Q160 70 165 85 Q160 95 155 85 Z" fill="url(#flammiEmber)" opacity="0.6" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="6s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M40 130 Q45 115 50 130 Q45 140 40 130 Z" fill="url(#flammiEmber)" opacity="0.7" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="7s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M160 125 Q165 110 170 125 Q165 135 160 125 Z" fill="url(#flammiEmber)" opacity="0.5" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="5s" repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Flammy Body -->
|
||||
<path d="M 100 160 Q 60 140 50 110 Q 45 80 70 60 Q 80 40 100 25 Q 120 40 130 60 Q 155 80 150 110 Q 140 140 100 160 Z" fill="url(#flammiBody)" />
|
||||
<path d="M 100 155 Q 65 138 58 115 Q 55 90 75 70 Q 82 50 100 35 Q 118 50 125 70 Q 145 90 142 115 Q 135 138 100 155 Z" fill="url(#flammiInner)" opacity="0.8" />
|
||||
<path d="M 100 145 Q 70 130 65 110 Q 62 95 80 80 Q 85 65 100 55 Q 115 65 120 80 Q 138 95 135 110 Q 130 130 100 145 Z" fill="url(#flammiCore)" opacity="0.9" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="88" cy="100" r="10" fill="white" />
|
||||
<circle cx="112" cy="100" r="10" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="88" cy="100" r="6" fill="#1f2937" />
|
||||
<circle cx="112" cy="100" r="6" fill="#1f2937" />
|
||||
<circle cx="90" cy="98" r="3" fill="white" />
|
||||
<circle cx="114" cy="98" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 115 Q 100 125 112 115" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Arms -->
|
||||
<ellipse cx="55" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(-30 55 110)" />
|
||||
<ellipse cx="145" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(30 145 110)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,75 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<!-- Flame gradients -->
|
||||
<radialGradient id="flammiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fbbf24" />
|
||||
<stop offset="30%" stop-color="#f97316" />
|
||||
<stop offset="70%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fde047" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiCore" cx="0.5" cy="0.4">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fde047" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f97316" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#b91c1c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="flammiEmber" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fde047" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Slower rotating flames for sleeping state -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M45 80 Q50 65 55 80 Q50 90 45 80 Z" fill="url(#flammiEmber)" opacity="0.5" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="8s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M155 85 Q160 70 165 85 Q160 95 155 85 Z" fill="url(#flammiEmber)" opacity="0.4" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="12s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M40 130 Q45 115 50 130 Q45 140 40 130 Z" fill="url(#flammiEmber)" opacity="0.4" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="14s" repeatCount="indefinite" />
|
||||
</g>
|
||||
<g transform="rotate(0 100 110)">
|
||||
<path d="M160 125 Q165 110 170 125 Q165 135 160 125 Z" fill="url(#flammiEmber)" opacity="0.3" />
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="10s" repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Flammy Body -->
|
||||
<path d="M 100 160 Q 60 140 50 110 Q 45 80 70 60 Q 80 40 100 25 Q 120 40 130 60 Q 155 80 150 110 Q 140 140 100 160 Z" fill="url(#flammiBody)" />
|
||||
<path d="M 100 155 Q 65 138 58 115 Q 55 90 75 70 Q 82 50 100 35 Q 118 50 125 70 Q 145 90 142 115 Q 135 138 100 155 Z" fill="url(#flammiInner)" opacity="0.8" />
|
||||
<path d="M 100 145 Q 70 130 65 110 Q 62 95 80 80 Q 85 65 100 55 Q 115 65 120 80 Q 138 95 135 110 Q 130 130 100 145 Z" fill="url(#flammiCore)" opacity="0.9" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 78 100 Q 88 103 98 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 100 Q 112 103 122 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Arms -->
|
||||
<ellipse cx="55" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(-30 55 110)" />
|
||||
<ellipse cx="145" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(30 145 110)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="froggiBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#22c55e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#15803d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiEyeBase3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="froggiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="froggiMouthHighlight" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="froggiNostril3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiNostrilHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiFeet3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiFeetHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Flattened oval body -->
|
||||
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
|
||||
|
||||
<!-- Big circular pop-out eyes -->
|
||||
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
|
||||
<circle cx="130" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<circle cx="70" cy="80" r="22" fill="url(#froggiEyeWhite3D)" />
|
||||
<circle cx="130" cy="80" r="22" fill="url(#froggiEyeWhite3D)" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="70" cy="80" r="16" fill="url(#froggiPupil3D)" />
|
||||
<circle cx="130" cy="80" r="16" fill="url(#froggiPupil3D)" />
|
||||
<circle cx="74" cy="76" r="6" fill="white" opacity="0.9" />
|
||||
<circle cx="134" cy="76" r="6" fill="white" opacity="0.9" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 45 120 Q 100 145 155 120" stroke="url(#froggiMouth3D)" stroke-width="5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 50 122 Q 100 142 150 122" stroke="url(#froggiMouthHighlight)" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced nostrils -->
|
||||
<ellipse cx="90" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
|
||||
<ellipse cx="110" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
|
||||
<ellipse cx="90" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
|
||||
<ellipse cx="110" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
|
||||
|
||||
<!-- Enhanced webbed feet -->
|
||||
<ellipse cx="60" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
|
||||
<ellipse cx="140" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
|
||||
<ellipse cx="60" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
|
||||
<ellipse cx="140" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
|
||||
|
||||
<!-- Enhanced webbed toes -->
|
||||
<path d="M 43 160 Q 47 155 52 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 53 160 Q 57 155 62 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 63 160 Q 67 155 72 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 123 160 Q 127 155 132 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 133 160 Q 137 155 142 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 143 160 Q 147 155 152 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft skin texture details -->
|
||||
<ellipse cx="75" cy="135" rx="4" ry="3" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="125" cy="130" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="85" cy="145" rx="3" ry="2" fill="rgba(255,255,255,0.15)" />
|
||||
<ellipse cx="115" cy="140" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.15)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="froggiBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#22c55e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#15803d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiEyeBase3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="froggiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="froggiMouthHighlight" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="froggiNostril3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiNostrilHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiFeet3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="froggiFeetHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Flattened oval body -->
|
||||
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
|
||||
|
||||
<!-- Big circular pop-out eyes -->
|
||||
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
|
||||
<circle cx="130" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 54 80 Q 70 83 86 80" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 114 80 Q 130 83 146 80" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="125" r="2" fill="#1e293b" />
|
||||
|
||||
<!-- Enhanced nostrils -->
|
||||
<ellipse cx="90" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
|
||||
<ellipse cx="110" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
|
||||
<ellipse cx="90" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
|
||||
<ellipse cx="110" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
|
||||
|
||||
<!-- Enhanced webbed feet -->
|
||||
<ellipse cx="60" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
|
||||
<ellipse cx="140" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
|
||||
<ellipse cx="60" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
|
||||
<ellipse cx="140" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
|
||||
|
||||
<!-- Enhanced webbed toes -->
|
||||
<path d="M 43 160 Q 47 155 52 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 53 160 Q 57 155 62 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 63 160 Q 67 155 72 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 123 160 Q 127 155 132 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 133 160 Q 137 155 142 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 143 160 Q 147 155 152 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft skin texture details -->
|
||||
<ellipse cx="75" cy="135" rx="4" ry="3" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="125" cy="130" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.2)" />
|
||||
<ellipse cx="85" cy="145" rx="3" ry="2" fill="rgba(255,255,255,0.15)" />
|
||||
<ellipse cx="115" cy="140" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.15)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,116 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sunflower stem -->
|
||||
<rect x="96" y="120" width="8" height="55" fill="url(#leafyStem)" rx="4" />
|
||||
<rect x="98" y="125" width="4" height="50" fill="url(#leafyStemHighlight)" rx="2" opacity="0.6" />
|
||||
|
||||
<!-- Stem leaves -->
|
||||
<ellipse cx="85" cy="140" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(-30 85 140)" />
|
||||
<ellipse cx="115" cy="150" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(30 115 150)" />
|
||||
<ellipse cx="87" cy="140" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(-30 87 140)" opacity="0.7" />
|
||||
<ellipse cx="113" cy="150" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(30 113 150)" opacity="0.7" />
|
||||
|
||||
<!-- Sunflower petals - outer ring -->
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(0 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(22.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(45 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(67.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(90 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(112.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(135 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(157.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(180 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(202.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(225 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(247.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(270 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(292.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(315 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(337.5 100 85)" />
|
||||
|
||||
<!-- Sunflower center - outer ring -->
|
||||
<circle cx="100" cy="85" r="30" fill="url(#leafyCenter)" />
|
||||
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="90" cy="82" r="8" fill="white" />
|
||||
<circle cx="110" cy="82" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="90" cy="82" r="5" fill="#1f2937" />
|
||||
<circle cx="110" cy="82" r="5" fill="#1f2937" />
|
||||
<circle cx="92" cy="80" r="2" fill="white" />
|
||||
<circle cx="112" cy="80" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 92 Q 100 100 112 92" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Floating pollen -->
|
||||
<circle cx="55" cy="60" r="2" fill="url(#leafyPollen)" opacity="0.8" />
|
||||
<circle cx="145" cy="65" r="1.5" fill="url(#leafyPollen)" opacity="0.6" />
|
||||
<circle cx="50" cy="110" r="1" fill="url(#leafyPollen)" opacity="0.7" />
|
||||
<circle cx="150" cy="105" r="2" fill="url(#leafyPollen)" opacity="0.5" />
|
||||
<circle cx="75" cy="45" r="1.5" fill="url(#leafyPollen)" opacity="0.9" />
|
||||
<circle cx="125" cy="50" r="1" fill="url(#leafyPollen)" opacity="0.8" />
|
||||
|
||||
<!-- Leavy pot -->
|
||||
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#leafyPot)" />
|
||||
<rect x="75" y="160" width="50" height="5" fill="url(#leafyPotRim)" rx="2" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="leafyStem" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemLeaf" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemLeafHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#4ade80" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
|
||||
<stop offset="100%" stop-color="#eab308" />
|
||||
<!-- <stop offset="70%" stop-color="#eab308" /> -->
|
||||
<stop offset="30%" stop-color="#fde047" />
|
||||
<stop offset="0%" stop-color="#ffce09" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a16207" />
|
||||
<stop offset="50%" stop-color="#92400e" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyCenterInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#d97706" />
|
||||
<stop offset="100%" stop-color="#a16207" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafySeed" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#451a03" />
|
||||
<stop offset="100%" stop-color="#292524" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyRoot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a16207" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fde047" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPotRim" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,113 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sunflower stem -->
|
||||
<rect x="96" y="120" width="8" height="55" fill="url(#leafyStem)" rx="4" />
|
||||
<rect x="98" y="125" width="4" height="50" fill="url(#leafyStemHighlight)" rx="2" opacity="0.6" />
|
||||
|
||||
<!-- Stem leaves -->
|
||||
<ellipse cx="85" cy="140" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(-30 85 140)" />
|
||||
<ellipse cx="115" cy="150" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(30 115 150)" />
|
||||
<ellipse cx="87" cy="140" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(-30 87 140)" opacity="0.7" />
|
||||
<ellipse cx="113" cy="150" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(30 113 150)" opacity="0.7" />
|
||||
|
||||
<!-- Sunflower petals - outer ring -->
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(0 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(22.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(45 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(67.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(90 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(112.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(135 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(157.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(180 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(202.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(225 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(247.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(270 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(292.5 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(315 100 85)" />
|
||||
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(337.5 100 85)" />
|
||||
|
||||
<!-- Sunflower center - outer ring -->
|
||||
<circle cx="100" cy="85" r="30" fill="url(#leafyCenter)" />
|
||||
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 82 82 Q 90 85 98 82" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 82 Q 110 85 118 82" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="92" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Little arms - small leaves -->
|
||||
<ellipse cx="60" cy="85" rx="12" ry="6" fill="url(#leafyArm)" transform="rotate(-20 60 85)" />
|
||||
<ellipse cx="140" cy="85" rx="12" ry="6" fill="url(#leafyArm)" transform="rotate(20 140 85)" />
|
||||
|
||||
<!-- Base/roots -->
|
||||
<ellipse cx="95" cy="170" rx="8" ry="6" fill="url(#leafyRoot)" />
|
||||
<ellipse cx="105" cy="170" rx="8" ry="6" fill="url(#leafyRoot)" />
|
||||
|
||||
<!-- Floating pollen with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 2,3; 0,0" dur="4s" repeatCount="indefinite" />
|
||||
<circle cx="55" cy="60" r="2" fill="url(#leafyPollen)" opacity="0.5" />
|
||||
<circle cx="145" cy="65" r="1.5" fill="url(#leafyPollen)" opacity="0.4" />
|
||||
<circle cx="50" cy="110" r="1" fill="url(#leafyPollen)" opacity="0.4" />
|
||||
<circle cx="150" cy="105" r="2" fill="url(#leafyPollen)" opacity="0.3" />
|
||||
<circle cx="75" cy="45" r="1.5" fill="url(#leafyPollen)" opacity="0.6" />
|
||||
<circle cx="125" cy="50" r="1" fill="url(#leafyPollen)" opacity="0.5" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="leafyStem" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemLeaf" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyStemLeafHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#4ade80" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="30%" stop-color="#fde047" />
|
||||
<stop offset="100%" stop-color="#eab308" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a16207" />
|
||||
<stop offset="50%" stop-color="#92400e" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyCenterInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#d97706" />
|
||||
<stop offset="100%" stop-color="#a16207" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafySeed" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#451a03" />
|
||||
<stop offset="100%" stop-color="#292524" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyRoot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a16207" />
|
||||
<stop offset="100%" stop-color="#78350f" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fde047" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,72 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Mushroom stem -->
|
||||
<ellipse cx="100" cy="140" rx="25" ry="40" fill="url(#mushieStem)" />
|
||||
<ellipse cx="100" cy="135" rx="20" ry="35" fill="url(#mushieStemHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Mushroom cap -->
|
||||
<path d="M 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" />
|
||||
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Cap spots -->
|
||||
<circle cx="80" cy="85" r="8" fill="white" opacity="0.8" />
|
||||
<circle cx="120" cy="80" r="10" fill="white" opacity="0.8" />
|
||||
<circle cx="100" cy="95" r="6" fill="white" opacity="0.7" />
|
||||
<circle cx="130" cy="100" r="5" fill="white" opacity="0.6" />
|
||||
<circle cx="70" cy="100" r="5" fill="white" opacity="0.6" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="88" cy="130" r="8" fill="white" />
|
||||
<circle cx="112" cy="130" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="88" cy="130" r="5" fill="#1f2937" />
|
||||
<circle cx="112" cy="130" r="5" fill="#1f2937" />
|
||||
<circle cx="90" cy="128" r="2" fill="white" />
|
||||
<circle cx="114" cy="128" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 145 Q 100 153 112 145" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<ellipse cx="70" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(-20 70 140)" />
|
||||
<ellipse cx="130" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(20 130 140)" />
|
||||
|
||||
<!-- Floating spores -->
|
||||
<circle cx="55" cy="120" r="2" fill="url(#mushieSpore)" opacity="0.8" />
|
||||
<circle cx="145" cy="115" r="1.5" fill="url(#mushieSpore)" opacity="0.6" />
|
||||
<circle cx="50" cy="90" r="1" fill="url(#mushieSpore)" opacity="0.7" />
|
||||
<circle cx="150" cy="95" r="2" fill="url(#mushieSpore)" opacity="0.5" />
|
||||
<circle cx="65" cy="70" r="1.5" fill="url(#mushieSpore)" opacity="0.6" />
|
||||
<circle cx="135" cy="65" r="1" fill="url(#mushieSpore)" opacity="0.8" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="mushieStem" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="30%" stop-color="#fde68a" />
|
||||
<stop offset="70%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieStemHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieCap" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#f87171" />
|
||||
<stop offset="30%" stop-color="#ef4444" />
|
||||
<stop offset="70%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#b91c1c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieCapHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fca5a5" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fde68a" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieSpore" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#c084fc" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Mushroom stem -->
|
||||
<ellipse cx="100" cy="140" rx="25" ry="40" fill="url(#mushieStem)" />
|
||||
<ellipse cx="100" cy="135" rx="20" ry="35" fill="url(#mushieStemHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Mushroom cap -->
|
||||
<path d="M 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" />
|
||||
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
|
||||
|
||||
<!-- Cap spots -->
|
||||
<circle cx="80" cy="85" r="8" fill="white" opacity="0.8" />
|
||||
<circle cx="120" cy="80" r="10" fill="white" opacity="0.8" />
|
||||
<circle cx="100" cy="95" r="6" fill="white" opacity="0.7" />
|
||||
<circle cx="130" cy="100" r="5" fill="white" opacity="0.6" />
|
||||
<circle cx="70" cy="100" r="5" fill="white" opacity="0.6" />
|
||||
|
||||
<!-- Sleeping eyes on stem -->
|
||||
<path d="M 80 130 Q 88 133 96 130" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 104 130 Q 112 133 120 130" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="145" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<ellipse cx="70" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(-20 70 140)" />
|
||||
<ellipse cx="130" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(20 130 140)" />
|
||||
|
||||
<!-- Floating spores with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 1,2; 0,0" dur="5s" repeatCount="indefinite" />
|
||||
<circle cx="55" cy="120" r="2" fill="url(#mushieSpore)" opacity="0.5" />
|
||||
<circle cx="145" cy="115" r="1.5" fill="url(#mushieSpore)" opacity="0.4" />
|
||||
<circle cx="50" cy="90" r="1" fill="url(#mushieSpore)" opacity="0.4" />
|
||||
<circle cx="150" cy="95" r="2" fill="url(#mushieSpore)" opacity="0.3" />
|
||||
<circle cx="65" cy="70" r="1.5" fill="url(#mushieSpore)" opacity="0.4" />
|
||||
<circle cx="135" cy="65" r="1" fill="url(#mushieSpore)" opacity="0.5" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="mushieStem" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="30%" stop-color="#fde68a" />
|
||||
<stop offset="70%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieStemHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieCap" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#f87171" />
|
||||
<stop offset="30%" stop-color="#ef4444" />
|
||||
<stop offset="70%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#b91c1c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieCapHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fca5a5" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fde68a" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="mushieSpore" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#c084fc" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="owliBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#78716c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEar3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEarInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliBeak3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliBeakHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliWing3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliWingHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Round body -->
|
||||
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" />
|
||||
|
||||
<!-- Triangle ears -->
|
||||
<path d="M 60 70 L 70 48 L 82 70 Z" fill="url(#owliEar3D)" />
|
||||
<path d="M 118 70 L 130 48 L 140 70 Z" fill="url(#owliEar3D)" />
|
||||
<path d="M 65 65 L 70 52 L 77 65 Z" fill="url(#owliEarInner)" />
|
||||
<path d="M 123 65 L 130 52 L 135 65 Z" fill="url(#owliEarInner)" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="80" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
|
||||
<circle cx="120" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="80" cy="100" r="14" fill="url(#owliPupil3D)" />
|
||||
<circle cx="120" cy="100" r="14" fill="url(#owliPupil3D)" />
|
||||
<circle cx="84" cy="96" r="5" fill="white" opacity="0.9" />
|
||||
<circle cx="124" cy="96" r="5" fill="white" opacity="0.9" />
|
||||
|
||||
<!-- Enhanced beak -->
|
||||
<path d="M 100 112 L 94 122 L 100 128 L 106 122 Z" fill="url(#owliBeak3D)" />
|
||||
<path d="M 100 114 L 96 120 L 100 124 L 104 120 Z" fill="url(#owliBeakHighlight)" />
|
||||
|
||||
<!-- Wing details -->
|
||||
<ellipse cx="48" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(-20 48 110)" />
|
||||
<ellipse cx="152" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(20 152 110)" />
|
||||
<ellipse cx="50" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(-20 50 108)" />
|
||||
<ellipse cx="150" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(20 150 108)" />
|
||||
|
||||
<!-- Soft feather texture details -->
|
||||
<circle cx="70" cy="130" r="3" fill="rgba(255,255,255,0.2)" />
|
||||
<circle cx="130" cy="125" r="2.5" fill="rgba(255,255,255,0.2)" />
|
||||
<circle cx="85" cy="140" r="2" fill="rgba(255,255,255,0.15)" />
|
||||
<circle cx="115" cy="135" r="2.5" fill="rgba(255,255,255,0.15)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="owliBody3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#78716c;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEar3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEarInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliBeak3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliBeakHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliWing3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="owliWingHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#a8a29e;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Round body -->
|
||||
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" />
|
||||
|
||||
<!-- Triangle ears -->
|
||||
<path d="M 60 70 L 70 48 L 82 70 Z" fill="url(#owliEar3D)" />
|
||||
<path d="M 118 70 L 130 48 L 140 70 Z" fill="url(#owliEar3D)" />
|
||||
<path d="M 65 65 L 70 52 L 77 65 Z" fill="url(#owliEarInner)" />
|
||||
<path d="M 123 65 L 130 52 L 135 65 Z" fill="url(#owliEarInner)" />
|
||||
|
||||
<!-- Large expressive eyes -->
|
||||
<circle cx="80" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
|
||||
<circle cx="120" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 58 100 Q 80 103 102 100" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 98 100 Q 120 103 142 100" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Enhanced beak -->
|
||||
<path d="M 100 112 L 94 122 L 100 128 L 106 122 Z" fill="url(#owliBeak3D)" />
|
||||
<path d="M 100 114 L 96 120 L 100 124 L 104 120 Z" fill="url(#owliBeakHighlight)" />
|
||||
|
||||
<!-- Wing details -->
|
||||
<ellipse cx="48" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(-20 48 110)" />
|
||||
<ellipse cx="152" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(20 152 110)" />
|
||||
<ellipse cx="50" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(-20 50 108)" />
|
||||
<ellipse cx="150" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(20 150 108)" />
|
||||
|
||||
<!-- Soft feather texture details -->
|
||||
<circle cx="70" cy="130" r="3" fill="rgba(255,255,255,0.2)" />
|
||||
<circle cx="130" cy="125" r="2.5" fill="rgba(255,255,255,0.2)" />
|
||||
<circle cx="85" cy="140" r="2" fill="rgba(255,255,255,0.15)" />
|
||||
<circle cx="115" cy="135" r="2.5" fill="rgba(255,255,255,0.15)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="pandiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiNose3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="pandiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="pandiArm3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiLeg3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main body - perfect circle -->
|
||||
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
|
||||
|
||||
<!-- Head - perfect circle -->
|
||||
<circle cx="100" cy="85" r="45" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
|
||||
|
||||
<!-- Black ear patches -->
|
||||
<circle cx="70" cy="45" r="18" fill="#1f2937" />
|
||||
<circle cx="130" cy="45" r="18" fill="#1f2937" />
|
||||
|
||||
<!-- Inner ears -->
|
||||
<circle cx="70" cy="45" r="12" fill="#374151" />
|
||||
<circle cx="130" cy="45" r="12" fill="#374151" />
|
||||
|
||||
<!-- Eyes (black patches + white base) -->
|
||||
<circle cx="85" cy="82" r="20" fill="#1f2937" />
|
||||
<circle cx="115" cy="82" r="20" fill="#1f2937" />
|
||||
<circle cx="85" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
|
||||
<circle cx="115" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="82" r="8" fill="url(#pandiPupil3D)" />
|
||||
<circle cx="115" cy="82" r="8" fill="url(#pandiPupil3D)" />
|
||||
<circle cx="88" cy="79" r="3" fill="white" />
|
||||
<circle cx="118" cy="79" r="3" fill="white" />
|
||||
|
||||
<!-- Nose -->
|
||||
<path d="M 100 95 L 95 105 L 105 105 Z" fill="url(#pandiNose3D)" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 90 110 Q 100 118 110 110" stroke="url(#pandiMouth3D)" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Arms -->
|
||||
<circle cx="45" cy="120" r="15" fill="url(#pandiArm3D)" />
|
||||
<circle cx="155" cy="120" r="15" fill="url(#pandiArm3D)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<circle cx="80" cy="165" r="18" fill="url(#pandiLeg3D)" />
|
||||
<circle cx="120" cy="165" r="18" fill="url(#pandiLeg3D)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Gradients for 3D effect -->
|
||||
<radialGradient id="pandiEyeWhite3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiPupil3D" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiNose3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="pandiMouth3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="pandiArm3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="pandiLeg3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main body - perfect circle -->
|
||||
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
|
||||
|
||||
<!-- Head - perfect circle -->
|
||||
<circle cx="100" cy="85" r="45" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
|
||||
|
||||
<!-- Black ear patches -->
|
||||
<circle cx="70" cy="45" r="18" fill="#1f2937" />
|
||||
<circle cx="130" cy="45" r="18" fill="#1f2937" />
|
||||
|
||||
<!-- Inner ears -->
|
||||
<circle cx="70" cy="45" r="12" fill="#374151" />
|
||||
<circle cx="130" cy="45" r="12" fill="#374151" />
|
||||
|
||||
<!-- Eyes -->
|
||||
<circle cx="85" cy="82" r="20" fill="#1f2937" />
|
||||
<circle cx="115" cy="82" r="20" fill="#1f2937" />
|
||||
<circle cx="85" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
|
||||
<circle cx="115" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
|
||||
<path d="M 73 85 Q 85 88 97 85" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 103 85 Q 115 88 127 85" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Nose -->
|
||||
<path d="M 100 95 L 95 105 L 105 105 Z" fill="url(#pandiNose3D)" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="110" r="2" fill="#1e293b" />
|
||||
|
||||
<!-- Arms -->
|
||||
<circle cx="55" cy="120" r="15" fill="url(#pandiArm3D)" />
|
||||
<circle cx="145" cy="120" r="15" fill="url(#pandiArm3D)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<circle cx="80" cy="165" r="18" fill="url(#pandiLeg3D)" />
|
||||
<circle cx="120" cy="165" r="18" fill="url(#pandiLeg3D)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,100 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Bolinha 1 - sentido horário -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="50" cy="80" r="4" fill="url(#rockyPebble)" opacity="0.8" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="5s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 2 - sentido anti-horário -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="150" cy="85" r="3" fill="url(#rockyPebble)" opacity="0.6" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="6s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 3 - sentido horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="45" cy="140" r="2.5" fill="url(#rockyPebble)" opacity="0.7" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="8s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 4 - sentido anti-horário (mais rápido) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="155" cy="135" r="3.5" fill="url(#rockyPebble)" opacity="0.5" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="4s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Rocky's body -->
|
||||
<path d="M 100 50 L 130 70 L 140 110 L 130 150 L 100 165 L 70 150 L 60 110 L 70 70 Z" fill="url(#rockyBody)" />
|
||||
<path d="M 100 55 L 125 72 L 135 108 L 125 145 L 100 158 L 75 145 L 65 108 L 75 72 Z" fill="url(#rockyInner)" opacity="0.8" />
|
||||
|
||||
<!-- Texture -->
|
||||
<path d="M 75 80 L 125 85" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 70 110 L 130 115" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 80 140 L 120 135" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="85" cy="95" r="12" fill="white" />
|
||||
<circle cx="115" cy="95" r="12" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="95" r="8" fill="#1f2937" />
|
||||
<circle cx="115" cy="95" r="8" fill="#1f2937" />
|
||||
<circle cx="88" cy="92" r="4" fill="white" />
|
||||
<circle cx="118" cy="92" r="4" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 115 Q 100 123 112 115" stroke="#1f2937" stroke-width="4" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Arms -->
|
||||
<ellipse cx="55" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(-15 55 110)" />
|
||||
<ellipse cx="145" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(15 145 110)" />
|
||||
|
||||
<!-- Legs -->
|
||||
<ellipse cx="85" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
|
||||
<ellipse cx="115" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="rockyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a8a29e" />
|
||||
<stop offset="30%" stop-color="#78716c" />
|
||||
<stop offset="70%" stop-color="#57534e" />
|
||||
<stop offset="100%" stop-color="#44403c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#d6d3d1" />
|
||||
<stop offset="100%" stop-color="#a8a29e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#78716c" />
|
||||
<stop offset="100%" stop-color="#44403c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#57534e" />
|
||||
<stop offset="100%" stop-color="#292524" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyPebble" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#a8a29e" />
|
||||
<stop offset="100%" stop-color="#57534e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,104 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sombra -->
|
||||
<ellipse cx="105" cy="185" rx="50" ry="8" fill="rgba(0,0,0,0.2)" />
|
||||
|
||||
<!-- Bolinha 1 - sentido horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="50" cy="80" r="4" fill="url(#rockyPebble)" opacity="0.5" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="10s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 2 - sentido anti-horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="150" cy="85" r="3" fill="url(#rockyPebble)" opacity="0.4" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="12s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 3 - sentido horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="45" cy="140" r="2.5" fill="url(#rockyPebble)" opacity="0.4" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="16s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Bolinha 4 - sentido anti-horário (mais lento) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<circle cx="155" cy="135" r="3.5" fill="url(#rockyPebble)" opacity="0.3" />
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="8s"
|
||||
repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Corpo do Rocky -->
|
||||
<path d="M 100 50 L 130 70 L 140 110 L 130 150 L 100 165 L 70 150 L 60 110 L 70 70 Z"
|
||||
fill="url(#rockyBody)" />
|
||||
<path d="M 100 55 L 125 72 L 135 108 L 125 145 L 100 158 L 75 145 L 65 108 L 75 72 Z"
|
||||
fill="url(#rockyInner)" opacity="0.8" />
|
||||
|
||||
<!-- Textura -->
|
||||
<path d="M 75 80 L 125 85" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 70 110 L 130 115" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 80 140 L 120 135" stroke="#57534e" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 73 95 Q 85 98 97 95" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 103 95 Q 115 98 127 95" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Braços -->
|
||||
<ellipse cx="55" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(-15 55 110)" />
|
||||
<ellipse cx="145" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(15 145 110)" />
|
||||
|
||||
<!-- Pernas -->
|
||||
<ellipse cx="85" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
|
||||
<ellipse cx="115" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="rockyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#a8a29e" />
|
||||
<stop offset="30%" stop-color="#78716c" />
|
||||
<stop offset="70%" stop-color="#57534e" />
|
||||
<stop offset="100%" stop-color="#44403c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#d6d3d1" />
|
||||
<stop offset="100%" stop-color="#a8a29e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#78716c" />
|
||||
<stop offset="100%" stop-color="#44403c" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#57534e" />
|
||||
<stop offset="100%" stop-color="#292524" />
|
||||
</radialGradient>
|
||||
<radialGradient id="rockyPebble" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#a8a29e" />
|
||||
<stop offset="100%" stop-color="#57534e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,94 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Rose stem -->
|
||||
<rect x="98" y="120" width="4" height="50" fill="url(#roseyStem)" rx="2" />
|
||||
|
||||
<!-- Thorns -->
|
||||
<path d="M 98 130 L 94 128" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M 102 145 L 106 143" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
|
||||
|
||||
<!-- Leaves -->
|
||||
<ellipse cx="85" cy="145" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(-30 85 140)" />
|
||||
<ellipse cx="110" cy="150" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(30 115 150)" />
|
||||
|
||||
<!-- Rose petals - layered -->
|
||||
<circle cx="100" cy="90" r="35" fill="url(#roseyPetal1)" />
|
||||
<path d="M 100 60 Q 120 70 125 90 Q 120 110 100 120 Q 80 110 75 90 Q 80 70 100 60" fill="url(#roseyPetal2)" />
|
||||
<path d="M 100 65 Q 115 73 118 90 Q 115 107 100 115 Q 85 107 82 90 Q 85 73 100 65" fill="url(#roseyPetal3)" />
|
||||
<circle cx="100" cy="90" r="20" fill="url(#roseyCenter)" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="90" cy="85" r="8" fill="white" />
|
||||
<circle cx="110" cy="85" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="90" cy="85" r="5" fill="#1f2937" />
|
||||
<circle cx="110" cy="85" r="5" fill="#1f2937" />
|
||||
<circle cx="92" cy="83" r="2" fill="white" />
|
||||
<circle cx="112" cy="83" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 92 100 Q 100 106 108 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Rosy cheeks -->
|
||||
<circle cx="75" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
|
||||
<circle cx="125" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
|
||||
|
||||
<!-- Floating petals -->
|
||||
<ellipse cx="55" cy="70" rx="8" ry="5" fill="url(#roseyFloatingPetal)" opacity="0.8" transform="rotate(45 55 70)" />
|
||||
<ellipse cx="145" cy="75" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.6" transform="rotate(-30 145 75)" />
|
||||
<ellipse cx="50" cy="120" rx="6" ry="3.5" fill="url(#roseyFloatingPetal)" opacity="0.7" transform="rotate(60 50 120)" />
|
||||
<ellipse cx="150" cy="115" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.5" transform="rotate(-45 150 115)" />
|
||||
|
||||
<!-- Rosey pot -->
|
||||
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#leafyPot)" />
|
||||
<rect x="75" y="160" width="50" height="5" fill="url(#leafyPotRim)" rx="2" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="roseyStem" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyLeaf" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal1" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="30%" stop-color="#f9a8d4" />
|
||||
<stop offset="70%" stop-color="#f472b6" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal2" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fbcfe8" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal3" cx="0.5" cy="0.4">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f9a8d4" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyCenter" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f9a8d4" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f9a8d4" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyFloatingPetal" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fbcfe8" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPot" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="leafyPotRim" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#ef4444" />
|
||||
<stop offset="100%" stop-color="#dc2626" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,88 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Rose stem -->
|
||||
<rect x="98" y="120" width="4" height="50" fill="url(#roseyStem)" rx="2" />
|
||||
|
||||
<!-- Thorns -->
|
||||
<path d="M 98 130 L 94 128" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M 102 145 L 106 143" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
|
||||
|
||||
<!-- Leaves -->
|
||||
<ellipse cx="85" cy="140" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(-30 85 140)" />
|
||||
<ellipse cx="115" cy="150" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(30 115 150)" />
|
||||
|
||||
<!-- Rose petals - layered -->
|
||||
<circle cx="100" cy="90" r="35" fill="url(#roseyPetal1)" />
|
||||
<path d="M 100 60 Q 120 70 125 90 Q 120 110 100 120 Q 80 110 75 90 Q 80 70 100 60" fill="url(#roseyPetal2)" />
|
||||
<path d="M 100 65 Q 115 73 118 90 Q 115 107 100 115 Q 85 107 82 90 Q 85 73 100 65" fill="url(#roseyPetal3)" />
|
||||
<circle cx="100" cy="90" r="20" fill="url(#roseyCenter)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 82 85 Q 90 88 98 85" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 85 Q 110 88 118 85" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="100" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Rosy cheeks -->
|
||||
<circle cx="75" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
|
||||
<circle cx="125" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
|
||||
|
||||
<!-- Little arms from center -->
|
||||
<ellipse cx="70" cy="90" rx="8" ry="12" fill="url(#roseyArm)" transform="rotate(-30 70 90)" />
|
||||
<ellipse cx="130" cy="90" rx="8" ry="12" fill="url(#roseyArm)" transform="rotate(30 130 90)" />
|
||||
|
||||
<!-- Floating petals with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 2,3; 0,0" dur="6s" repeatCount="indefinite" />
|
||||
<ellipse cx="55" cy="70" rx="8" ry="5" fill="url(#roseyFloatingPetal)" opacity="0.5" transform="rotate(45 55 70)" />
|
||||
<ellipse cx="145" cy="75" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.4" transform="rotate(-30 145 75)" />
|
||||
<ellipse cx="50" cy="120" rx="6" ry="3.5" fill="url(#roseyFloatingPetal)" opacity="0.4" transform="rotate(60 50 120)" />
|
||||
<ellipse cx="150" cy="115" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.3" transform="rotate(-45 150 115)" />
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="roseyStem" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyLeaf" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal1" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="30%" stop-color="#f9a8d4" />
|
||||
<stop offset="70%" stop-color="#f472b6" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal2" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fbcfe8" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyPetal3" cx="0.5" cy="0.4">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f9a8d4" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyCenter" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f9a8d4" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#f9a8d4" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</radialGradient>
|
||||
<radialGradient id="roseyFloatingPetal" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fbcfe8" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Star gradients -->
|
||||
<radialGradient id="starriBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4c1d95;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#3730a3;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#1e1b4b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriEye" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="starriSmile" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="starriDust1" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriDust2" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriDust3" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="starriConstellation" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main star body - larger 5-pointed star shape -->
|
||||
<path d="M 100 25 L 115 75 L 165 75 L 125 110 L 140 160 L 100 130 L 60 160 L 75 110 L 35 75 L 85 75 Z" fill="url(#starriBody)" />
|
||||
<path d="M 100 35 L 112 70 L 150 70 L 120 95 L 132 135 L 100 115 L 68 135 L 80 95 L 50 70 L 88 70 Z" fill="url(#starriInner)" opacity="0.8" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="88" cy="95" r="10" fill="url(#starriEye)" />
|
||||
<circle cx="112" cy="95" r="10" fill="url(#starriEye)" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="88" cy="95" r="6" fill="#1e1b4b" />
|
||||
<circle cx="112" cy="95" r="6" fill="#1e1b4b" />
|
||||
<circle cx="90" cy="93" r="3" fill="white" />
|
||||
<circle cx="114" cy="93" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 115 Q 100 125 112 115" stroke="url(#starriSmile)" stroke-width="4" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Floating stardust -->
|
||||
<circle cx="55" cy="60" r="2" fill="url(#starriDust1)" opacity="0.9" />
|
||||
<circle cx="145" cy="65" r="1.5" fill="url(#starriDust2)" opacity="0.8" />
|
||||
<circle cx="60" cy="140" r="2.5" fill="url(#starriDust3)" opacity="0.7" />
|
||||
<circle cx="140" cy="135" r="2" fill="url(#starriDust1)" opacity="0.6" />
|
||||
<circle cx="40" cy="100" r="1.5" fill="url(#starriDust2)" opacity="0.8" />
|
||||
<circle cx="160" cy="105" r="2" fill="url(#starriDust3)" opacity="0.9" />
|
||||
|
||||
<!-- Constellation lines -->
|
||||
<path d="M 55 60 L 70 45 L 130 50 L 145 65" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.5" />
|
||||
<path d="M 40 100 L 60 140 L 140 135 L 160 105" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Star gradients -->
|
||||
<radialGradient id="starriBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:#4c1d95;stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:#3730a3;stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:#1e1b4b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriEye" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="starriSmile" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="starriDust1" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriDust2" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<radialGradient id="starriDust3" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
<linearGradient id="starriConstellation" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c084fc;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main star body - larger 5-pointed star shape -->
|
||||
<path d="M 100 25 L 115 75 L 165 75 L 125 110 L 140 160 L 100 130 L 60 160 L 75 110 L 35 75 L 85 75 Z"
|
||||
fill="url(#starriBody)" />
|
||||
<path d="M 100 35 L 112 70 L 150 70 L 120 95 L 132 135 L 100 115 L 68 135 L 80 95 L 50 70 L 88 70 Z"
|
||||
fill="url(#starriInner)" opacity="0.8" />
|
||||
|
||||
<!-- Twinkling eyes -->
|
||||
<circle cx="88" cy="95" r="10" fill="url(#starriEye)" />
|
||||
<circle cx="112" cy="95" r="10" fill="url(#starriEye)" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 102 95 Q 112 98 122 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="100" cy="115" r="2" fill="#f59e0b" />
|
||||
|
||||
<!-- Floating stardust with gentle animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,0; 1,2; 0,0" dur="4s" repeatCount="indefinite" />
|
||||
<circle cx="55" cy="60" r="2" fill="url(#starriDust1)" opacity="0.6" />
|
||||
<circle cx="145" cy="65" r="1.5" fill="url(#starriDust2)" opacity="0.5" />
|
||||
<circle cx="60" cy="140" r="2.5" fill="url(#starriDust3)" opacity="0.4" />
|
||||
<circle cx="140" cy="135" r="2" fill="url(#starriDust1)" opacity="0.4" />
|
||||
<circle cx="40" cy="100" r="1.5" fill="url(#starriDust2)" opacity="0.5" />
|
||||
<circle cx="160" cy="105" r="2" fill="url(#starriDust3)" opacity="0.6" />
|
||||
</g>
|
||||
|
||||
<!-- Constellation lines -->
|
||||
<path d="M 55 60 L 70 45 L 130 50 L 145 65" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.3" />
|
||||
<path d="M 40 100 L 60 140 L 140 135 L 160 105" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.3" />
|
||||
|
||||
<!-- "Zzz" sleeping -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Adult Blobbi Module
|
||||
*
|
||||
* Self-contained module for adult stage Blobbi visuals and customization.
|
||||
* This module includes:
|
||||
* - Adult SVG assets (awake and sleeping variants for each form)
|
||||
* - SVG resolution and loading utilities
|
||||
* - Color and customization utilities
|
||||
* - Type definitions
|
||||
*
|
||||
* This module is designed to be portable and can be moved to other projects.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AdultForm,
|
||||
AdultVariant,
|
||||
AdultSvgCustomization,
|
||||
AdultSvgResolverOptions,
|
||||
} from './types/adult.types';
|
||||
|
||||
export {
|
||||
ADULT_FORMS,
|
||||
extractAdultCustomization,
|
||||
isValidAdultForm,
|
||||
getDefaultAdultForm,
|
||||
resolveAdultForm,
|
||||
deriveAdultFormFromSeed,
|
||||
} from './types/adult.types';
|
||||
|
||||
// SVG Resolution
|
||||
export {
|
||||
getAdultBaseSvg,
|
||||
getAdultSleepingSvg,
|
||||
getAdultSvgByVariant,
|
||||
resolveAdultSvg,
|
||||
resolveAdultSvgWithForm,
|
||||
getAvailableAdultForms,
|
||||
preloadAdultSvgs,
|
||||
} from './lib/adult-svg-resolver';
|
||||
|
||||
// SVG Customization
|
||||
export {
|
||||
customizeAdultSvg,
|
||||
customizeAdultSvgFromBlobbi,
|
||||
} from './lib/adult-svg-customizer';
|
||||
@@ -0,0 +1,764 @@
|
||||
/**
|
||||
* Adult Blobbi SVG Customizer
|
||||
*
|
||||
* Handles applying colors and customizations to adult SVG content.
|
||||
* Each adult form has different gradient IDs that need color mapping.
|
||||
*
|
||||
* IMPORTANT: Gradients must be preserved for 3D shading effects.
|
||||
* We replace gradient colors, not the gradient structure.
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import type { AdultForm, AdultSvgCustomization } from '../types/adult.types';
|
||||
|
||||
// ─── Color Utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lighten a hex color by a percentage
|
||||
*/
|
||||
function lightenColor(color: string, percent: number): string {
|
||||
if (color.startsWith('#')) {
|
||||
const num = parseInt(color.slice(1), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = (num >> 16) + amt;
|
||||
const G = (num >> 8 & 0x00FF) + amt;
|
||||
const B = (num & 0x0000FF) + amt;
|
||||
return '#' + (
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
||||
).toString(16).slice(1).toUpperCase();
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken a hex color by a percentage
|
||||
*/
|
||||
function darkenColor(color: string, percent: number): string {
|
||||
if (color.startsWith('#')) {
|
||||
const num = parseInt(color.slice(1), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = (num >> 16) - amt;
|
||||
const G = (num >> 8 & 0x00FF) - amt;
|
||||
const B = (num & 0x0000FF) - amt;
|
||||
return '#' + (
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
||||
).toString(16).slice(1).toUpperCase();
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// ─── Gradient Builders ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a 3-stop radial gradient (highlight -> mid -> base)
|
||||
*/
|
||||
function buildRadialGradient3Stop(
|
||||
id: string,
|
||||
baseColor: string,
|
||||
cx = '0.3',
|
||||
cy = '0.2'
|
||||
): string {
|
||||
const highlight = lightenColor(baseColor, 40);
|
||||
const mid = lightenColor(baseColor, 20);
|
||||
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
|
||||
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
|
||||
<stop offset="40%" style="stop-color:${mid};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 2-stop radial gradient (lighter -> base)
|
||||
*/
|
||||
function buildRadialGradient2Stop(
|
||||
id: string,
|
||||
baseColor: string,
|
||||
cx = '0.3',
|
||||
cy = '0.3'
|
||||
): string {
|
||||
const highlight = lightenColor(baseColor, 25);
|
||||
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
|
||||
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 4-stop radial gradient (used by droppi, rocky, starri bodies)
|
||||
*/
|
||||
function buildRadialGradient4Stop(
|
||||
id: string,
|
||||
baseColor: string,
|
||||
cx = '0.3',
|
||||
cy = '0.2'
|
||||
): string {
|
||||
const veryLight = lightenColor(baseColor, 50);
|
||||
const light = lightenColor(baseColor, 25);
|
||||
const dark = darkenColor(baseColor, 15);
|
||||
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
|
||||
<stop offset="0%" style="stop-color:${veryLight};stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:${light};stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${dark};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a petal gradient (outer -> inner style, like rosey/leafy)
|
||||
*/
|
||||
function buildPetalGradient(
|
||||
id: string,
|
||||
baseColor: string,
|
||||
cx = '0.3',
|
||||
cy = '0.2'
|
||||
): string {
|
||||
const veryLight = lightenColor(baseColor, 50);
|
||||
const light = lightenColor(baseColor, 30);
|
||||
const mid = lightenColor(baseColor, 15);
|
||||
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
|
||||
<stop offset="0%" style="stop-color:${veryLight};stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:${light};stop-opacity:1" />
|
||||
<stop offset="70%" style="stop-color:${mid};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pupil gradient
|
||||
*/
|
||||
function buildPupilGradient(id: string, eyeColor: string): string {
|
||||
const highlight = lightenColor(eyeColor, 20);
|
||||
return `<radialGradient id="${id}" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${eyeColor};stop-opacity:1" />
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
// ─── Generic Gradient Replacer ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace a specific gradient in the SVG by ID
|
||||
*/
|
||||
function replaceGradient(
|
||||
svgText: string,
|
||||
gradientId: string,
|
||||
newGradient: string
|
||||
): string {
|
||||
// Match both radialGradient and linearGradient
|
||||
const pattern = new RegExp(
|
||||
`<(radial|linear)Gradient[^>]*id=["']${gradientId}["'][^>]*>[\\s\\S]*?<\\/(radial|linear)Gradient>`,
|
||||
'i'
|
||||
);
|
||||
|
||||
const match = svgText.match(pattern);
|
||||
if (match) {
|
||||
return svgText.replace(match[0], newGradient);
|
||||
}
|
||||
return svgText;
|
||||
}
|
||||
|
||||
// ─── Form-Specific Customizers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Catti: Body, ears, and tail should use Blobbi color
|
||||
* Gradients: cattiBody3D, cattiEar3D, cattiEarInner, cattiTail3D, cattiTailHighlight
|
||||
*/
|
||||
function customizeCatti(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body gradient (3-stop)
|
||||
svg = replaceGradient(svg, 'cattiBody3D', buildRadialGradient3Stop('cattiBody3D', baseColor));
|
||||
|
||||
// Ear gradients (2-stop)
|
||||
svg = replaceGradient(svg, 'cattiEar3D', buildRadialGradient2Stop('cattiEar3D', baseColor));
|
||||
|
||||
// Ear inner uses lighter color
|
||||
const earInnerColor = lightenColor(baseColor, 20);
|
||||
svg = replaceGradient(svg, 'cattiEarInner', buildRadialGradient2Stop('cattiEarInner', earInnerColor, '0.4', '0.3'));
|
||||
|
||||
// Tail gradients
|
||||
const tailHighlight = lightenColor(baseColor, 40);
|
||||
svg = replaceGradient(svg, 'cattiTail3D', `<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 15)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
svg = replaceGradient(svg, 'cattiTailHighlight', buildRadialGradient2Stop('cattiTailHighlight', tailHighlight, '0.4', '0.3'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Droppi: Body, arms, legs, and droplets should use Blobbi color
|
||||
* Gradients: droppiBody, droppiInner, droppiArm, droppiLeg, droppiDroplet
|
||||
*/
|
||||
function customizeDroppi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop)
|
||||
svg = replaceGradient(svg, 'droppiBody', buildRadialGradient4Stop('droppiBody', baseColor));
|
||||
|
||||
// Inner reflection (lighter, 2-stop)
|
||||
const innerColor = lightenColor(baseColor, 45);
|
||||
svg = replaceGradient(svg, 'droppiInner', buildRadialGradient2Stop('droppiInner', innerColor, '0.4', '0.3'));
|
||||
|
||||
// Arms (2-stop)
|
||||
svg = replaceGradient(svg, 'droppiArm', buildRadialGradient2Stop('droppiArm', lightenColor(baseColor, 15)));
|
||||
|
||||
// Legs (2-stop, slightly darker)
|
||||
svg = replaceGradient(svg, 'droppiLeg', buildRadialGradient2Stop('droppiLeg', darkenColor(baseColor, 5), '0.3', '0.2'));
|
||||
|
||||
// Droplets
|
||||
svg = replaceGradient(svg, 'droppiDroplet', buildRadialGradient2Stop('droppiDroplet', lightenColor(baseColor, 30), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flammi: Body, inner, core, arms, legs, and embers should use Blobbi color
|
||||
* Gradients: flammiBody, flammiInner, flammiCore, flammiArm, flammiLeg, flammiEmber
|
||||
*/
|
||||
function customizeFlammi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop gradient with warm progression)
|
||||
svg = replaceGradient(svg, 'flammiBody', buildRadialGradient4Stop('flammiBody', baseColor));
|
||||
|
||||
// Inner (3-stop, lighter)
|
||||
const innerColor = lightenColor(baseColor, 25);
|
||||
svg = replaceGradient(svg, 'flammiInner', `<radialGradient id="flammiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(innerColor, 30)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${innerColor};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 10)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
// Core (hottest/brightest part, very light)
|
||||
const coreColor = lightenColor(baseColor, 50);
|
||||
svg = replaceGradient(svg, 'flammiCore', buildRadialGradient2Stop('flammiCore', coreColor, '0.5', '0.4'));
|
||||
|
||||
// Arms
|
||||
svg = replaceGradient(svg, 'flammiArm', buildRadialGradient2Stop('flammiArm', lightenColor(baseColor, 10)));
|
||||
|
||||
// Legs
|
||||
svg = replaceGradient(svg, 'flammiLeg', buildRadialGradient2Stop('flammiLeg', baseColor, '0.3', '0.2'));
|
||||
|
||||
// Embers
|
||||
svg = replaceGradient(svg, 'flammiEmber', buildRadialGradient2Stop('flammiEmber', lightenColor(baseColor, 35), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Froggi: Body, eye base, feet should use Blobbi color
|
||||
* Gradients: froggiBody3D, froggiEyeBase3D, froggiFeet3D, froggiFeetHighlight, froggiToe3D
|
||||
*/
|
||||
function customizeFroggi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (3-stop)
|
||||
svg = replaceGradient(svg, 'froggiBody3D', buildRadialGradient3Stop('froggiBody3D', baseColor));
|
||||
|
||||
// Eye base (matches body color, 2-stop)
|
||||
svg = replaceGradient(svg, 'froggiEyeBase3D', buildRadialGradient2Stop('froggiEyeBase3D', lightenColor(baseColor, 15)));
|
||||
|
||||
// Feet (2-stop, lighter than body)
|
||||
const feetColor = lightenColor(baseColor, 20);
|
||||
svg = replaceGradient(svg, 'froggiFeet3D', buildRadialGradient2Stop('froggiFeet3D', feetColor, '0.3', '0.2'));
|
||||
|
||||
// Feet highlight (even lighter)
|
||||
svg = replaceGradient(svg, 'froggiFeetHighlight', buildRadialGradient2Stop('froggiFeetHighlight', lightenColor(feetColor, 20), '0.4', '0.3'));
|
||||
|
||||
// Toes (linear gradient, darker)
|
||||
svg = replaceGradient(svg, 'froggiToe3D', `<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:${darkenColor(baseColor, 10)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 25)};stop-opacity:1" />
|
||||
</linearGradient>`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leafy: Petals should use Blobbi color (center/face keeps brown)
|
||||
* Gradients: leafyPetal (petals only - the yellow parts)
|
||||
*/
|
||||
function customizeLeafy(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Petal gradient (the sunflower petals)
|
||||
svg = replaceGradient(svg, 'leafyPetal', `<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
|
||||
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 15)};stop-opacity:1" />
|
||||
<stop offset="30%" style="stop-color:${lightenColor(baseColor, 25)};stop-opacity:1" />
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mushie: Cap should use Blobbi color (stem keeps original)
|
||||
* Gradients: mushieCap, mushieCapHighlight
|
||||
*/
|
||||
function customizeMushie(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Cap (4-stop)
|
||||
svg = replaceGradient(svg, 'mushieCap', buildRadialGradient4Stop('mushieCap', baseColor));
|
||||
|
||||
// Cap highlight (lighter)
|
||||
svg = replaceGradient(svg, 'mushieCapHighlight', buildRadialGradient2Stop('mushieCapHighlight', lightenColor(baseColor, 25), '0.4', '0.3'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rocky: Body, inner, arms, legs, and pebbles should use Blobbi color
|
||||
* Gradients: rockyBody, rockyInner, rockyArm, rockyLeg, rockyPebble
|
||||
*/
|
||||
function customizeRocky(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop)
|
||||
svg = replaceGradient(svg, 'rockyBody', buildRadialGradient4Stop('rockyBody', baseColor));
|
||||
|
||||
// Inner (2-stop, lighter)
|
||||
svg = replaceGradient(svg, 'rockyInner', buildRadialGradient2Stop('rockyInner', lightenColor(baseColor, 35), '0.4', '0.3'));
|
||||
|
||||
// Arms (2-stop)
|
||||
svg = replaceGradient(svg, 'rockyArm', buildRadialGradient2Stop('rockyArm', baseColor));
|
||||
|
||||
// Legs (2-stop, slightly darker)
|
||||
svg = replaceGradient(svg, 'rockyLeg', buildRadialGradient2Stop('rockyLeg', darkenColor(baseColor, 10), '0.3', '0.2'));
|
||||
|
||||
// Pebbles
|
||||
svg = replaceGradient(svg, 'rockyPebble', buildRadialGradient2Stop('rockyPebble', lightenColor(baseColor, 15), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rosey: Petals, center, and floating petals should use Blobbi color
|
||||
* Gradients: roseyPetal1, roseyPetal2, roseyPetal3, roseyCenter, roseyFloatingPetal
|
||||
*/
|
||||
function customizeRosey(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Petal layers (outer to inner, using petal gradient style)
|
||||
svg = replaceGradient(svg, 'roseyPetal1', buildPetalGradient('roseyPetal1', baseColor));
|
||||
|
||||
// Petal2 (slightly lighter)
|
||||
svg = replaceGradient(svg, 'roseyPetal2', buildRadialGradient2Stop('roseyPetal2', lightenColor(baseColor, 15), '0.4', '0.3'));
|
||||
|
||||
// Petal3 (lightest inner petals)
|
||||
svg = replaceGradient(svg, 'roseyPetal3', buildRadialGradient2Stop('roseyPetal3', lightenColor(baseColor, 30), '0.5', '0.4'));
|
||||
|
||||
// Center (where face is, slightly darker)
|
||||
svg = replaceGradient(svg, 'roseyCenter', buildRadialGradient2Stop('roseyCenter', lightenColor(baseColor, 10)));
|
||||
|
||||
// Floating petals
|
||||
svg = replaceGradient(svg, 'roseyFloatingPetal', buildRadialGradient2Stop('roseyFloatingPetal', lightenColor(baseColor, 20), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starri: Inner star should use Blobbi color (outer stays dark/cosmic)
|
||||
* Gradients: starriInner (the inner golden star - this should be the Blobbi color)
|
||||
*/
|
||||
function customizeStarri(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Inner star (3-stop gradient to maintain depth)
|
||||
svg = replaceGradient(svg, 'starriInner', `<radialGradient id="starriInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breezy: Body, inner, veins, arms, legs, and floating leaves should use Blobbi color
|
||||
* Gradients: breezyBody, breezyInner, breezyVein, breezyArm, breezyLeg, breezyFloating
|
||||
*/
|
||||
function customizeBreezy(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop leaf gradient)
|
||||
svg = replaceGradient(svg, 'breezyBody', buildRadialGradient4Stop('breezyBody', baseColor));
|
||||
|
||||
// Inner highlight (lighter, 2-stop)
|
||||
svg = replaceGradient(svg, 'breezyInner', buildRadialGradient2Stop('breezyInner', lightenColor(baseColor, 40), '0.4', '0.3'));
|
||||
|
||||
// Veins (linear gradient, darker)
|
||||
svg = replaceGradient(svg, 'breezyVein', `<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:${darkenColor(baseColor, 20)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${darkenColor(baseColor, 10)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 20)};stop-opacity:1" />
|
||||
</linearGradient>`);
|
||||
|
||||
// Arms (2-stop)
|
||||
svg = replaceGradient(svg, 'breezyArm', buildRadialGradient2Stop('breezyArm', lightenColor(baseColor, 15)));
|
||||
|
||||
// Legs (2-stop)
|
||||
svg = replaceGradient(svg, 'breezyLeg', buildRadialGradient2Stop('breezyLeg', baseColor, '0.3', '0.2'));
|
||||
|
||||
// Floating leaves
|
||||
svg = replaceGradient(svg, 'breezyFloating', buildRadialGradient2Stop('breezyFloating', lightenColor(baseColor, 25), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloomi: Petals, center, and pollen should use Blobbi color
|
||||
* Note: Bloomi has 6 different colored petals - we'll make them all use variations of the base color
|
||||
* Gradients: bloomiPetal1-6, bloomiCenter, bloomiPollen
|
||||
*/
|
||||
function customizeBloomi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// All 6 petals use variations of the Blobbi color
|
||||
// Create a gradient effect across petals by varying lightness
|
||||
svg = replaceGradient(svg, 'bloomiPetal1', buildRadialGradient2Stop('bloomiPetal1', lightenColor(baseColor, 30)));
|
||||
svg = replaceGradient(svg, 'bloomiPetal2', buildRadialGradient2Stop('bloomiPetal2', lightenColor(baseColor, 20)));
|
||||
svg = replaceGradient(svg, 'bloomiPetal3', buildRadialGradient2Stop('bloomiPetal3', lightenColor(baseColor, 10)));
|
||||
svg = replaceGradient(svg, 'bloomiPetal4', buildRadialGradient2Stop('bloomiPetal4', baseColor));
|
||||
svg = replaceGradient(svg, 'bloomiPetal5', buildRadialGradient2Stop('bloomiPetal5', darkenColor(baseColor, 10)));
|
||||
svg = replaceGradient(svg, 'bloomiPetal6', buildRadialGradient2Stop('bloomiPetal6', darkenColor(baseColor, 5)));
|
||||
|
||||
// Center (3-stop, lighter than petals - this is where the face is)
|
||||
svg = replaceGradient(svg, 'bloomiCenter', `<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 45)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 25)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
// Pollen (floating particles)
|
||||
svg = replaceGradient(svg, 'bloomiPollen', buildRadialGradient2Stop('bloomiPollen', lightenColor(baseColor, 40), '0.5', '0.5'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cacti: Body and arms should use Blobbi color (pot keeps original red)
|
||||
* Gradients: cactiBody, cactiArm
|
||||
*/
|
||||
function customizeCacti(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop)
|
||||
svg = replaceGradient(svg, 'cactiBody', buildRadialGradient4Stop('cactiBody', baseColor));
|
||||
|
||||
// Arms (2-stop)
|
||||
svg = replaceGradient(svg, 'cactiArm', buildRadialGradient2Stop('cactiArm', lightenColor(baseColor, 10)));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudi: Body, highlights, and raindrops should use Blobbi color
|
||||
* Gradients: cloudiBody, cloudiHighlight, cloudiRain
|
||||
*/
|
||||
function customizeCloudi(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (3-stop, cloud-like progression from light to slightly darker)
|
||||
svg = replaceGradient(svg, 'cloudiBody', `<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 45)};stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 30)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
|
||||
</radialGradient>`);
|
||||
|
||||
// Highlights (very light, semi-transparent feel)
|
||||
svg = replaceGradient(svg, 'cloudiHighlight', `<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 50)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 40)};stop-opacity:0.5" />
|
||||
</radialGradient>`);
|
||||
|
||||
// Raindrops (use darker version of the color)
|
||||
svg = replaceGradient(svg, 'cloudiRain', buildRadialGradient2Stop('cloudiRain', darkenColor(baseColor, 10), '0.5', '0.3'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crysti: Body and inner should use Blobbi color (facets keep their colorful nature)
|
||||
* Gradients: crystiBody, crystiInner
|
||||
*/
|
||||
function customizeCrysti(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (4-stop crystal gradient)
|
||||
svg = replaceGradient(svg, 'crystiBody', buildRadialGradient4Stop('crystiBody', baseColor));
|
||||
|
||||
// Inner highlight (semi-transparent white feel preserved but tinted)
|
||||
svg = replaceGradient(svg, 'crystiInner', `<radialGradient id="crystiInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 50)};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:0.3" />
|
||||
</radialGradient>`);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owli: Body, ears, and wings should use Blobbi color (beak keeps yellow/orange)
|
||||
* Gradients: owliBody3D, owliEar3D, owliWing3D, owliWingHighlight
|
||||
*/
|
||||
function customizeOwli(svgText: string, baseColor: string): string {
|
||||
let svg = svgText;
|
||||
|
||||
// Body (3-stop)
|
||||
svg = replaceGradient(svg, 'owliBody3D', buildRadialGradient3Stop('owliBody3D', baseColor));
|
||||
|
||||
// Ears (2-stop, slightly darker)
|
||||
svg = replaceGradient(svg, 'owliEar3D', buildRadialGradient2Stop('owliEar3D', darkenColor(baseColor, 10), '0.3', '0.2'));
|
||||
|
||||
// Wings (2-stop)
|
||||
svg = replaceGradient(svg, 'owliWing3D', buildRadialGradient2Stop('owliWing3D', darkenColor(baseColor, 15), '0.3', '0.2'));
|
||||
|
||||
// Wing highlights (lighter)
|
||||
svg = replaceGradient(svg, 'owliWingHighlight', buildRadialGradient2Stop('owliWingHighlight', lightenColor(baseColor, 10), '0.4', '0.3'));
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// ─── Form Customizer Map ──────────────────────────────────────────────────────
|
||||
|
||||
type FormCustomizer = (svgText: string, baseColor: string) => string;
|
||||
|
||||
const FORM_CUSTOMIZERS: Partial<Record<AdultForm, FormCustomizer>> = {
|
||||
bloomi: customizeBloomi,
|
||||
breezy: customizeBreezy,
|
||||
cacti: customizeCacti,
|
||||
catti: customizeCatti,
|
||||
cloudi: customizeCloudi,
|
||||
crysti: customizeCrysti,
|
||||
droppi: customizeDroppi,
|
||||
flammi: customizeFlammi,
|
||||
froggi: customizeFroggi,
|
||||
leafy: customizeLeafy,
|
||||
mushie: customizeMushie,
|
||||
owli: customizeOwli,
|
||||
rocky: customizeRocky,
|
||||
rosey: customizeRosey,
|
||||
starri: customizeStarri,
|
||||
// pandi keeps original colors - it's a panda with black/white coloring by design
|
||||
};
|
||||
|
||||
// ─── Main Customization ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply color customizations to adult SVG.
|
||||
*
|
||||
* Each form has specific gradients that need to be replaced
|
||||
* to apply the Blobbi's custom colors while preserving 3D shading.
|
||||
*
|
||||
* @param svgText - The SVG content to customize
|
||||
* @param form - The adult form type
|
||||
* @param customization - Color customization options
|
||||
* @param isSleeping - Whether the Blobbi is sleeping (affects eye rendering)
|
||||
* @param instanceId - Optional unique ID to prevent gradient ID collisions when multiple Blobbis are rendered
|
||||
*/
|
||||
export function customizeAdultSvg(
|
||||
svgText: string,
|
||||
form: AdultForm,
|
||||
customization: AdultSvgCustomization,
|
||||
isSleeping: boolean = false,
|
||||
instanceId?: string
|
||||
): string {
|
||||
let modifiedSvg = svgText;
|
||||
|
||||
// Ensure SVG fills its container
|
||||
modifiedSvg = ensureSvgFillsContainer(modifiedSvg);
|
||||
|
||||
// Skip color customization if no colors provided
|
||||
if (!customization.baseColor && !customization.secondaryColor && !customization.eyeColor) {
|
||||
// Still uniquify IDs if instanceId provided (even without color changes)
|
||||
if (instanceId) {
|
||||
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
|
||||
}
|
||||
return modifiedSvg;
|
||||
}
|
||||
|
||||
// Apply form-specific body/part customization
|
||||
if (customization.baseColor) {
|
||||
const customizer = FORM_CUSTOMIZERS[form];
|
||||
if (customizer) {
|
||||
modifiedSvg = customizer(modifiedSvg, customization.baseColor);
|
||||
} else {
|
||||
// Fallback for forms without specific customizer: try generic body gradient
|
||||
modifiedSvg = applyGenericBodyGradient(modifiedSvg, form, customization.baseColor);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply eye color customization (skip for sleeping SVGs - eyes are closed)
|
||||
if (customization.eyeColor && !isSleeping) {
|
||||
modifiedSvg = applyPupilGradient(modifiedSvg, form, customization.eyeColor);
|
||||
}
|
||||
|
||||
// Make all IDs unique to prevent collisions when multiple Blobbis are rendered
|
||||
if (instanceId) {
|
||||
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
|
||||
}
|
||||
|
||||
return modifiedSvg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure SVG has width/height attributes so it fills its container
|
||||
*/
|
||||
function ensureSvgFillsContainer(svgText: string): string {
|
||||
if (/\swidth=/.test(svgText) && /\sheight=/.test(svgText)) {
|
||||
return svgText;
|
||||
}
|
||||
|
||||
return svgText.replace(
|
||||
/<svg([^>]*)>/,
|
||||
'<svg$1 width="100%" height="100%">'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make all SVG definition IDs unique by prefixing with an instance ID.
|
||||
* This prevents gradient ID collisions when multiple Blobbis are rendered on the same page.
|
||||
*
|
||||
* Updates both:
|
||||
* - Definition IDs: id="gradientName" → id="prefix_gradientName"
|
||||
* - References: url(#gradientName) → url(#prefix_gradientName)
|
||||
*/
|
||||
function uniquifySvgIds(svgText: string, instanceId: string): string {
|
||||
// Generate a unique prefix from the full instance ID
|
||||
// Sanitize to only allow valid SVG ID characters (letters, numbers, underscore, hyphen)
|
||||
// Note: instanceId format is "blobbi-{pubkeyPrefix12}-{petId10}" so we need the full ID
|
||||
// to distinguish between Blobbis owned by the same user
|
||||
const prefix = `b_${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
||||
|
||||
// Find all IDs defined in the SVG (in defs, gradients, clipPaths, etc.)
|
||||
const idPattern = /\bid=["']([^"']+)["']/g;
|
||||
const ids = new Set<string>();
|
||||
let match;
|
||||
|
||||
while ((match = idPattern.exec(svgText)) !== null) {
|
||||
ids.add(match[1]);
|
||||
}
|
||||
|
||||
// Replace each ID and its references
|
||||
let modified = svgText;
|
||||
for (const id of ids) {
|
||||
const prefixedId = `${prefix}_${id}`;
|
||||
|
||||
// Replace the ID definition
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\bid=["']${id}["']`, 'g'),
|
||||
`id="${prefixedId}"`
|
||||
);
|
||||
|
||||
// Replace url() references
|
||||
modified = modified.replace(
|
||||
new RegExp(`url\\(#${id}\\)`, 'g'),
|
||||
`url(#${prefixedId})`
|
||||
);
|
||||
|
||||
// Replace xlink:href references (older SVG format)
|
||||
modified = modified.replace(
|
||||
new RegExp(`xlink:href=["']#${id}["']`, 'g'),
|
||||
`xlink:href="#${prefixedId}"`
|
||||
);
|
||||
|
||||
// Replace href references (newer SVG format)
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\bhref=["']#${id}["']`, 'g'),
|
||||
`href="#${prefixedId}"`
|
||||
);
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Apply generic body gradient for forms without specific customizer
|
||||
*/
|
||||
function applyGenericBodyGradient(
|
||||
svgText: string,
|
||||
form: AdultForm,
|
||||
baseColor: string
|
||||
): string {
|
||||
let modified = svgText;
|
||||
|
||||
// Try common patterns: {form}Body3D, {form}Body
|
||||
const bodyPatterns = [
|
||||
new RegExp(`<radialGradient[^>]*id=["'](${form}Body3D)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
|
||||
new RegExp(`<radialGradient[^>]*id=["'](${form}Body)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
|
||||
];
|
||||
|
||||
for (const pattern of bodyPatterns) {
|
||||
const match = modified.match(pattern);
|
||||
if (match) {
|
||||
const gradientId = match[1];
|
||||
const newGradient = buildRadialGradient3Stop(gradientId, baseColor);
|
||||
modified = modified.replace(match[0], newGradient);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pupil gradient customization
|
||||
*/
|
||||
function applyPupilGradient(
|
||||
svgText: string,
|
||||
form: AdultForm,
|
||||
eyeColor: string
|
||||
): string {
|
||||
let modified = svgText;
|
||||
|
||||
// Try common patterns: {form}Pupil3D, {form}Pupil
|
||||
const pupilPatterns = [
|
||||
new RegExp(`<radialGradient[^>]*id=["'](${form}Pupil3D)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
|
||||
new RegExp(`<radialGradient[^>]*id=["'](${form}Pupil)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
|
||||
];
|
||||
|
||||
for (const pattern of pupilPatterns) {
|
||||
const match = modified.match(pattern);
|
||||
if (match) {
|
||||
const gradientId = match[1];
|
||||
const newGradient = buildPupilGradient(gradientId, eyeColor);
|
||||
modified = modified.replace(match[0], newGradient);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
// ─── Convenience Functions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience function to customize adult SVG from a Blobbi instance.
|
||||
*
|
||||
* Uses the Blobbi's ID to uniquify SVG IDs, preventing gradient collisions
|
||||
* when multiple Blobbis are rendered on the same page.
|
||||
*/
|
||||
export function customizeAdultSvgFromBlobbi(
|
||||
svgText: string,
|
||||
form: AdultForm,
|
||||
blobbi: Blobbi,
|
||||
isSleeping: boolean = false
|
||||
): string {
|
||||
const customization: AdultSvgCustomization = {
|
||||
baseColor: blobbi.baseColor,
|
||||
secondaryColor: blobbi.secondaryColor,
|
||||
eyeColor: blobbi.eyeColor,
|
||||
};
|
||||
|
||||
// Pass blobbi.id to uniquify gradient IDs and prevent collisions
|
||||
return customizeAdultSvg(svgText, form, customization, isSleeping, blobbi.id);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Adult Blobbi SVG Resolver
|
||||
*
|
||||
* Handles loading and resolving adult stage SVG assets.
|
||||
* Each adult form has its own folder with base and sleeping variants.
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import {
|
||||
type AdultForm,
|
||||
type AdultSvgResolverOptions,
|
||||
ADULT_FORMS,
|
||||
resolveAdultForm,
|
||||
getDefaultAdultForm,
|
||||
} from '../types/adult.types';
|
||||
import { ADULT_SVG_MAP } from './adult-svg-data';
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get adult base SVG content for a specific form
|
||||
*/
|
||||
export function getAdultBaseSvg(form: AdultForm): string {
|
||||
return ADULT_SVG_MAP[form]?.base ?? getFallbackAdultSvg(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adult sleeping SVG content for a specific form
|
||||
*/
|
||||
export function getAdultSleepingSvg(form: AdultForm): string {
|
||||
return ADULT_SVG_MAP[form]?.sleeping ?? getFallbackAdultSvg(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adult SVG by form and variant
|
||||
*/
|
||||
export function getAdultSvgByVariant(
|
||||
form: AdultForm,
|
||||
variant: 'base' | 'sleeping'
|
||||
): string {
|
||||
return variant === 'sleeping'
|
||||
? getAdultSleepingSvg(form)
|
||||
: getAdultBaseSvg(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve adult Blobbi SVG content.
|
||||
*
|
||||
* Determines the correct form from blobbi data (evolutionForm or seed-derived),
|
||||
* then returns the appropriate SVG based on sleeping state.
|
||||
*/
|
||||
export function resolveAdultSvg(
|
||||
blobbi: Blobbi,
|
||||
options: AdultSvgResolverOptions = {}
|
||||
): string {
|
||||
const { isSleeping = false } = options;
|
||||
|
||||
if (blobbi.lifeStage !== 'adult') {
|
||||
console.warn('resolveAdultSvg called with non-adult Blobbi');
|
||||
return getFallbackAdultSvg(getDefaultAdultForm());
|
||||
}
|
||||
|
||||
const form = resolveAdultForm(blobbi);
|
||||
return isSleeping ? getAdultSleepingSvg(form) : getAdultBaseSvg(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve adult form from Blobbi and return both form and SVG
|
||||
*/
|
||||
export function resolveAdultSvgWithForm(
|
||||
blobbi: Blobbi,
|
||||
options: AdultSvgResolverOptions = {}
|
||||
): { form: AdultForm; svg: string } {
|
||||
const { isSleeping = false } = options;
|
||||
const form = resolveAdultForm(blobbi);
|
||||
const svg = isSleeping ? getAdultSleepingSvg(form) : getAdultBaseSvg(form);
|
||||
return { form, svg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available adult forms
|
||||
*/
|
||||
export function getAvailableAdultForms(): readonly AdultForm[] {
|
||||
return ADULT_FORMS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all adult SVGs for quick switching
|
||||
*/
|
||||
export function preloadAdultSvgs(): void {
|
||||
// All SVGs are inlined constants — this function exists for API consistency
|
||||
// This function exists for API consistency
|
||||
for (const form of ADULT_FORMS) {
|
||||
getAdultBaseSvg(form);
|
||||
getAdultSleepingSvg(form);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fallback ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get fallback adult SVG content.
|
||||
* Used when the expected asset is not found.
|
||||
*/
|
||||
function getFallbackAdultSvg(form: AdultForm): string {
|
||||
// Simple placeholder SVG that indicates the form name
|
||||
return `
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="fallbackAdultGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:#a78bfa"/>
|
||||
<stop offset="60%" style="stop-color:#8b5cf6"/>
|
||||
<stop offset="100%" style="stop-color:#7c3aed"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<!-- Body -->
|
||||
<ellipse cx="100" cy="110" rx="50" ry="60" fill="url(#fallbackAdultGradient)" />
|
||||
<!-- Eyes -->
|
||||
<ellipse cx="82" cy="95" rx="10" ry="12" fill="#fff" />
|
||||
<ellipse cx="118" cy="95" rx="10" ry="12" fill="#fff" />
|
||||
<circle cx="82" cy="96" r="7" fill="#374151" />
|
||||
<circle cx="118" cy="96" r="7" fill="#374151" />
|
||||
<circle cx="84" cy="94" r="2.5" fill="white" />
|
||||
<circle cx="120" cy="94" r="2.5" fill="white" />
|
||||
<!-- Mouth -->
|
||||
<path d="M 88 120 Q 100 130 112 120" stroke="#374151" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<!-- Form label (dev only) -->
|
||||
<text x="100" y="180" text-anchor="middle" font-size="12" fill="#666">${form}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Adult Blobbi Module Types
|
||||
*
|
||||
* Type definitions for adult stage visuals and customization
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
|
||||
/**
|
||||
* All available adult evolution forms.
|
||||
* Each form corresponds to a folder in assets/
|
||||
*/
|
||||
export const ADULT_FORMS = [
|
||||
'bloomi',
|
||||
'breezy',
|
||||
'cacti',
|
||||
'catti',
|
||||
'cloudi',
|
||||
'crysti',
|
||||
'droppi',
|
||||
'flammi',
|
||||
'froggi',
|
||||
'leafy',
|
||||
'mushie',
|
||||
'owli',
|
||||
'pandi',
|
||||
'rocky',
|
||||
'rosey',
|
||||
'starri',
|
||||
] as const;
|
||||
|
||||
export type AdultForm = typeof ADULT_FORMS[number];
|
||||
|
||||
/**
|
||||
* Adult visual variant types
|
||||
*/
|
||||
export type AdultVariant = 'base' | 'sleeping';
|
||||
|
||||
/**
|
||||
* Adult SVG customization options
|
||||
*/
|
||||
export interface AdultSvgCustomization {
|
||||
/** Base body color */
|
||||
baseColor?: string;
|
||||
/** Secondary body color */
|
||||
secondaryColor?: string;
|
||||
/** Eye/pupil color */
|
||||
eyeColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adult SVG resolver options
|
||||
*/
|
||||
export interface AdultSvgResolverOptions {
|
||||
/** Whether the adult is sleeping */
|
||||
isSleeping?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts adult-specific customization from a Blobbi
|
||||
*/
|
||||
export function extractAdultCustomization(blobbi: Blobbi): AdultSvgCustomization {
|
||||
return {
|
||||
baseColor: blobbi.baseColor,
|
||||
secondaryColor: blobbi.secondaryColor,
|
||||
eyeColor: blobbi.eyeColor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid adult form
|
||||
*/
|
||||
export function isValidAdultForm(form: string): form is AdultForm {
|
||||
return ADULT_FORMS.includes(form as AdultForm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default adult form (used as fallback)
|
||||
*/
|
||||
export function getDefaultAdultForm(): AdultForm {
|
||||
return 'catti';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves adult form from Blobbi data.
|
||||
* Uses adult.evolutionForm if set and valid, otherwise derives from seed.
|
||||
*/
|
||||
export function resolveAdultForm(blobbi: Blobbi): AdultForm {
|
||||
// Check explicit evolutionForm first
|
||||
if (blobbi.adult?.evolutionForm && isValidAdultForm(blobbi.adult.evolutionForm)) {
|
||||
return blobbi.adult.evolutionForm;
|
||||
}
|
||||
|
||||
// Derive from seed if available
|
||||
if (blobbi.seed) {
|
||||
return deriveAdultFormFromSeed(blobbi.seed);
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
return getDefaultAdultForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives adult form deterministically from a seed string.
|
||||
* Uses simple hash-based selection for consistency.
|
||||
*/
|
||||
export function deriveAdultFormFromSeed(seed: string): AdultForm {
|
||||
// Simple hash: sum of char codes
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
|
||||
// Convert to positive index
|
||||
const index = Math.abs(hash) % ADULT_FORMS.length;
|
||||
return ADULT_FORMS[index];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Baby Blobbi Module
|
||||
|
||||
Self-contained module for baby stage Blobbi visuals and customization.
|
||||
|
||||
## Overview
|
||||
|
||||
This module provides everything needed to render and customize baby stage Blobbis:
|
||||
|
||||
- **SVG Assets**: Base and sleeping variants
|
||||
- **SVG Resolution**: Loading and variant selection
|
||||
- **Customization**: Color and appearance customization
|
||||
- **Type Safety**: Full TypeScript support
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
src/blobbi/baby-blobbi/
|
||||
├── assets/
|
||||
│ ├── blobbi-baby-base.svg # Awake baby variant
|
||||
│ └── blobbi-baby-sleeping.svg # Sleeping baby variant
|
||||
├── lib/
|
||||
│ ├── baby-svg-resolver.ts # SVG loading and resolution
|
||||
│ └── baby-svg-customizer.ts # Color customization utilities
|
||||
├── types/
|
||||
│ └── baby.types.ts # Type definitions
|
||||
├── index.ts # Barrel exports
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic SVG Resolution
|
||||
|
||||
```typescript
|
||||
import { resolveBabySvg, getBabyBaseSvg, getBabySleepingSvg } from '@/blobbi/baby-blobbi';
|
||||
|
||||
// Get specific variant
|
||||
const awakeSvg = getBabyBaseSvg();
|
||||
const sleepingSvg = getBabySleepingSvg();
|
||||
|
||||
// Resolve from Blobbi instance
|
||||
const svg = resolveBabySvg(blobbi, { isSleeping: false });
|
||||
```
|
||||
|
||||
### Color Customization
|
||||
|
||||
```typescript
|
||||
import { customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
|
||||
|
||||
// Get base SVG
|
||||
const baseSvg = getBabyBaseSvg();
|
||||
|
||||
// Apply Blobbi's colors
|
||||
const customizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, false);
|
||||
```
|
||||
|
||||
### Preloading
|
||||
|
||||
```typescript
|
||||
import { preloadBabySvgs } from '@/blobbi/baby-blobbi';
|
||||
|
||||
// Preload all baby SVGs for quick switching
|
||||
preloadBabySvgs();
|
||||
```
|
||||
|
||||
## Customization Options
|
||||
|
||||
The module supports three color customizations:
|
||||
|
||||
- **baseColor**: Primary body color
|
||||
- **secondaryColor**: Secondary gradient color
|
||||
- **eyeColor**: Pupil/eye color (not applied to sleeping variant)
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Portability**: Self-contained, minimal external dependencies
|
||||
2. **Type Safety**: Full TypeScript coverage
|
||||
3. **Performance**: Eager loading via Vite for instant access
|
||||
4. **Consistency**: Follows established patterns from egg module
|
||||
5. **Separation**: Baby-specific logic isolated from adult/egg logic
|
||||
|
||||
## Integration
|
||||
|
||||
This module is designed to be:
|
||||
|
||||
- Imported via barrel exports from `@/blobbi/baby-blobbi`
|
||||
- Used alongside egg and adult modules
|
||||
- Easily moved to other projects with minimal changes
|
||||
|
||||
## Related Modules
|
||||
|
||||
- **Egg Module**: `src/egg/` - Egg stage visuals and incubation
|
||||
- **Adult Module**: Adult stage visuals (to be refactored similarly)
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Body gradient -->
|
||||
<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="60%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#6d28d9;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Eye gradient -->
|
||||
<radialGradient id="blobbiEyeGradient" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f1f5f9;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Pupil gradient -->
|
||||
<radialGradient id="blobbiPupilGradient" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Mouth gradient -->
|
||||
<linearGradient id="blobbiMouthGradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main body - cute water droplet shape -->
|
||||
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
|
||||
fill="url(#blobbiBodyGradient)" />
|
||||
|
||||
<!-- Soft inner glow -->
|
||||
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<ellipse cx="38" cy="45" rx="8" ry="10" fill="url(#blobbiEyeGradient)" />
|
||||
<ellipse cx="62" cy="45" rx="8" ry="10" fill="url(#blobbiEyeGradient)" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="38" cy="46" r="6" fill="url(#blobbiPupilGradient)" />
|
||||
<circle cx="62" cy="46" r="6" fill="url(#blobbiPupilGradient)" />
|
||||
<circle cx="40" cy="44" r="2" fill="white" />
|
||||
<circle cx="64" cy="44" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 42 62 Q 50 68 58 62" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft blush for cuteness -->
|
||||
<ellipse cx="22" cy="55" rx="6" ry="4" fill="rgba(255,182,193,0.5)" />
|
||||
<ellipse cx="78" cy="55" rx="6" ry="4" fill="rgba(255,182,193,0.5)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Body gradient -->
|
||||
<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="60%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#6d28d9;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Mouth gradient -->
|
||||
<linearGradient id="blobbiMouthGradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main body -->
|
||||
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
|
||||
fill="url(#blobbiBodyGradient)" />
|
||||
|
||||
<!-- Soft inner glow -->
|
||||
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 30 45 Q 40 48 45 45" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 55 45 Q 65 48 70 45" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="50" cy="65" r="1.5" fill="url(#blobbiMouthGradient)" />
|
||||
|
||||
<!-- Z's for sleeping -->
|
||||
<text x="75" y="25" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="80" y="20" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="83" y="16" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Baby Blobbi Module
|
||||
*
|
||||
* Self-contained module for baby stage Blobbi visuals and customization.
|
||||
* This module includes:
|
||||
* - Baby SVG assets (awake and sleeping)
|
||||
* - SVG resolution and loading utilities
|
||||
* - Color and customization utilities
|
||||
* - Type definitions
|
||||
*
|
||||
* This module is designed to be portable and can be moved to other projects.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
BabyVariant,
|
||||
BabySvgCustomization,
|
||||
BabySvgResolverOptions
|
||||
} from './types/baby.types';
|
||||
|
||||
export { extractBabyCustomization } from './types/baby.types';
|
||||
|
||||
// SVG Resolution
|
||||
export {
|
||||
getBabyBaseSvg,
|
||||
getBabySleepingSvg,
|
||||
getBabySvgByVariant,
|
||||
resolveBabySvg,
|
||||
preloadBabySvgs,
|
||||
} from './lib/baby-svg-resolver';
|
||||
|
||||
// SVG Customization
|
||||
export {
|
||||
customizeBabySvg,
|
||||
customizeBabySvgFromBlobbi,
|
||||
} from './lib/baby-svg-customizer';
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Baby Blobbi SVG Customizer
|
||||
*
|
||||
* Handles applying colors and customizations to baby SVG content
|
||||
*/
|
||||
|
||||
import { Blobbi } from '@/types/blobbi';
|
||||
import { BabySvgCustomization } from '../types/baby.types';
|
||||
|
||||
/**
|
||||
* Lighten a color by a percentage
|
||||
*/
|
||||
function lightenColor(color: string, percent: number): string {
|
||||
// Handle hex colors
|
||||
if (color.startsWith('#')) {
|
||||
const num = parseInt(color.slice(1), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = (num >> 16) + amt;
|
||||
const G = (num >> 8 & 0x00FF) + amt;
|
||||
const B = (num & 0x0000FF) + amt;
|
||||
return '#' + (
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
||||
).toString(16).slice(1).toUpperCase();
|
||||
}
|
||||
|
||||
// Return as-is for non-hex colors (rgb, etc.)
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply color customizations to baby SVG
|
||||
*
|
||||
* @param svgText - The SVG content to customize
|
||||
* @param customization - Color customization options
|
||||
* @param isSleeping - Whether the Blobbi is sleeping (affects eye rendering)
|
||||
* @param instanceId - Optional unique ID to prevent gradient ID collisions when multiple Blobbis are rendered
|
||||
*/
|
||||
export function customizeBabySvg(
|
||||
svgText: string,
|
||||
customization: BabySvgCustomization,
|
||||
isSleeping: boolean = false,
|
||||
instanceId?: string
|
||||
): string {
|
||||
let modifiedSvg = svgText;
|
||||
|
||||
// Ensure SVG fills its container by adding width/height attributes
|
||||
// This is needed because the SVG only has viewBox, and without explicit dimensions
|
||||
// it may not fill flex containers properly
|
||||
modifiedSvg = ensureSvgFillsContainer(modifiedSvg);
|
||||
|
||||
// Only apply customizations if we have colors
|
||||
if (!customization.baseColor && !customization.secondaryColor && !customization.eyeColor) {
|
||||
// Still uniquify IDs if instanceId provided (even without color changes)
|
||||
if (instanceId) {
|
||||
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
|
||||
}
|
||||
return modifiedSvg;
|
||||
}
|
||||
|
||||
// Apply body gradient customization
|
||||
if (customization.baseColor) {
|
||||
modifiedSvg = applyBodyGradient(modifiedSvg, customization);
|
||||
}
|
||||
|
||||
// Apply eye color customization (skip for sleeping SVGs - eyes are closed)
|
||||
if (customization.eyeColor && !isSleeping) {
|
||||
modifiedSvg = applyEyeColor(modifiedSvg, customization.eyeColor);
|
||||
}
|
||||
|
||||
// Make all IDs unique to prevent collisions when multiple Blobbis are rendered
|
||||
if (instanceId) {
|
||||
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
|
||||
}
|
||||
|
||||
return modifiedSvg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure SVG has width/height attributes so it fills its container
|
||||
*/
|
||||
function ensureSvgFillsContainer(svgText: string): string {
|
||||
// Check if width and height are already set
|
||||
if (/\swidth=/.test(svgText) && /\sheight=/.test(svgText)) {
|
||||
return svgText;
|
||||
}
|
||||
|
||||
// Add width="100%" height="100%" to the SVG tag
|
||||
return svgText.replace(
|
||||
/<svg([^>]*)>/,
|
||||
'<svg$1 width="100%" height="100%">'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make all SVG definition IDs unique by prefixing with an instance ID.
|
||||
* This prevents gradient ID collisions when multiple Blobbis are rendered on the same page.
|
||||
*
|
||||
* Updates both:
|
||||
* - Definition IDs: id="gradientName" → id="prefix_gradientName"
|
||||
* - References: url(#gradientName) → url(#prefix_gradientName)
|
||||
*/
|
||||
function uniquifySvgIds(svgText: string, instanceId: string): string {
|
||||
// Generate a unique prefix from the full instance ID
|
||||
// Sanitize to only allow valid SVG ID characters (letters, numbers, underscore, hyphen)
|
||||
// Note: instanceId format is "blobbi-{pubkeyPrefix12}-{petId10}" so we need the full ID
|
||||
// to distinguish between Blobbis owned by the same user
|
||||
const prefix = `b_${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
||||
|
||||
// Find all IDs defined in the SVG (in defs, gradients, clipPaths, etc.)
|
||||
const idPattern = /\bid=["']([^"']+)["']/g;
|
||||
const ids = new Set<string>();
|
||||
let match;
|
||||
|
||||
while ((match = idPattern.exec(svgText)) !== null) {
|
||||
ids.add(match[1]);
|
||||
}
|
||||
|
||||
// Replace each ID and its references
|
||||
let modified = svgText;
|
||||
for (const id of ids) {
|
||||
const prefixedId = `${prefix}_${id}`;
|
||||
|
||||
// Replace the ID definition
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\bid=["']${id}["']`, 'g'),
|
||||
`id="${prefixedId}"`
|
||||
);
|
||||
|
||||
// Replace url() references
|
||||
modified = modified.replace(
|
||||
new RegExp(`url\\(#${id}\\)`, 'g'),
|
||||
`url(#${prefixedId})`
|
||||
);
|
||||
|
||||
// Replace xlink:href references (older SVG format)
|
||||
modified = modified.replace(
|
||||
new RegExp(`xlink:href=["']#${id}["']`, 'g'),
|
||||
`xlink:href="#${prefixedId}"`
|
||||
);
|
||||
|
||||
// Replace href references (newer SVG format)
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\bhref=["']#${id}["']`, 'g'),
|
||||
`href="#${prefixedId}"`
|
||||
);
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply body gradient customization
|
||||
*/
|
||||
function applyBodyGradient(svgText: string, customization: BabySvgCustomization): string {
|
||||
const bodyGradientRegex = /<radialGradient[^>]*id=["']blobbiBodyGradient["'][^>]*>([\s\S]*?)<\/radialGradient>/;
|
||||
const bodyGradientMatch = svgText.match(bodyGradientRegex);
|
||||
|
||||
if (!bodyGradientMatch || !customization.baseColor) {
|
||||
return svgText;
|
||||
}
|
||||
|
||||
let newGradient = '';
|
||||
|
||||
if (customization.secondaryColor) {
|
||||
// Both base_color and secondary_color are present
|
||||
newGradient = `<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:${customization.secondaryColor}"/>
|
||||
<stop offset="60%" style="stop-color:${lightenColor(customization.secondaryColor, 20)}"/>
|
||||
<stop offset="100%" style="stop-color:${customization.baseColor}"/>
|
||||
</radialGradient>`;
|
||||
} else {
|
||||
// Only base_color is present
|
||||
newGradient = `<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(customization.baseColor, 40)}"/>
|
||||
<stop offset="60%" style="stop-color:${lightenColor(customization.baseColor, 20)}"/>
|
||||
<stop offset="100%" style="stop-color:${customization.baseColor}"/>
|
||||
</radialGradient>`;
|
||||
}
|
||||
|
||||
return svgText.replace(bodyGradientMatch[0], newGradient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply eye color customization
|
||||
*/
|
||||
function applyEyeColor(svgText: string, eyeColor: string): string {
|
||||
const eyeGradientRegex = /<radialGradient[^>]*id=["']blobbiPupilGradient["'][^>]*>([\s\S]*?)<\/radialGradient>/;
|
||||
const eyeGradientMatch = svgText.match(eyeGradientRegex);
|
||||
|
||||
if (!eyeGradientMatch) {
|
||||
return svgText;
|
||||
}
|
||||
|
||||
const newEyeGradient = `<radialGradient id="blobbiPupilGradient" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:${lightenColor(eyeColor, 30)}"/>
|
||||
<stop offset="100%" style="stop-color:${eyeColor}"/>
|
||||
</radialGradient>`;
|
||||
|
||||
return svgText.replace(eyeGradientMatch[0], newEyeGradient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to customize baby SVG from a Blobbi instance.
|
||||
*
|
||||
* Uses the Blobbi's ID to uniquify SVG IDs, preventing gradient collisions
|
||||
* when multiple Blobbis are rendered on the same page.
|
||||
*/
|
||||
export function customizeBabySvgFromBlobbi(
|
||||
svgText: string,
|
||||
blobbi: Blobbi,
|
||||
isSleeping: boolean = false
|
||||
): string {
|
||||
const customization: BabySvgCustomization = {
|
||||
baseColor: blobbi.baseColor,
|
||||
secondaryColor: blobbi.secondaryColor,
|
||||
eyeColor: blobbi.eyeColor,
|
||||
};
|
||||
|
||||
// Pass blobbi.id to uniquify gradient IDs and prevent collisions
|
||||
return customizeBabySvg(svgText, customization, isSleeping, blobbi.id);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Auto-generated: inlined SVG content for baby Blobbi assets.
|
||||
// Source: src/blobbi/baby-blobbi/assets/
|
||||
|
||||
export const BABY_BASE_SVG = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Body gradient -->
|
||||
<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="60%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#6d28d9;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Eye gradient -->
|
||||
<radialGradient id="blobbiEyeGradient" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f1f5f9;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Pupil gradient -->
|
||||
<radialGradient id="blobbiPupilGradient" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Mouth gradient -->
|
||||
<linearGradient id="blobbiMouthGradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main body - cute water droplet shape -->
|
||||
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
|
||||
fill="url(#blobbiBodyGradient)" />
|
||||
|
||||
<!-- Soft inner glow -->
|
||||
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<ellipse cx="38" cy="45" rx="8" ry="10" fill="url(#blobbiEyeGradient)" />
|
||||
<ellipse cx="62" cy="45" rx="8" ry="10" fill="url(#blobbiEyeGradient)" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="38" cy="46" r="6" fill="url(#blobbiPupilGradient)" />
|
||||
<circle cx="62" cy="46" r="6" fill="url(#blobbiPupilGradient)" />
|
||||
<circle cx="40" cy="44" r="2" fill="white" />
|
||||
<circle cx="64" cy="44" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 42 62 Q 50 68 58 62" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Soft blush for cuteness -->
|
||||
<ellipse cx="22" cy="55" rx="6" ry="4" fill="rgba(255,182,193,0.5)" />
|
||||
<ellipse cx="78" cy="55" rx="6" ry="4" fill="rgba(255,182,193,0.5)" />
|
||||
</svg>`;
|
||||
|
||||
export const BABY_SLEEPING_SVG = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Body gradient -->
|
||||
<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
<stop offset="60%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#6d28d9;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Mouth gradient -->
|
||||
<linearGradient id="blobbiMouthGradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main body -->
|
||||
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
|
||||
fill="url(#blobbiBodyGradient)" />
|
||||
|
||||
<!-- Soft inner glow -->
|
||||
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
|
||||
|
||||
<!-- Sleeping eyes -->
|
||||
<path d="M 30 45 Q 40 48 45 45" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
<path d="M 55 45 Q 65 48 70 45" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Peaceful mouth -->
|
||||
<circle cx="50" cy="65" r="1.5" fill="url(#blobbiMouthGradient)" />
|
||||
|
||||
<!-- Z's for sleeping -->
|
||||
<text x="75" y="25" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="80" y="20" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="83" y="16" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
</svg>`;
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Baby Blobbi SVG Resolver
|
||||
*
|
||||
* Handles loading and resolving baby stage SVG assets
|
||||
*/
|
||||
|
||||
import { Blobbi } from '@/types/blobbi';
|
||||
import { BabyVariant, BabySvgResolverOptions } from '../types/baby.types';
|
||||
import { BABY_BASE_SVG, BABY_SLEEPING_SVG } from './baby-svg-data';
|
||||
|
||||
/**
|
||||
* Get baby base SVG content
|
||||
*/
|
||||
export function getBabyBaseSvg(): string {
|
||||
return BABY_BASE_SVG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get baby sleeping SVG content
|
||||
*/
|
||||
export function getBabySleepingSvg(): string {
|
||||
return BABY_SLEEPING_SVG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get baby SVG by variant
|
||||
*/
|
||||
export function getBabySvgByVariant(variant: BabyVariant): string {
|
||||
return variant === 'sleeping' ? getBabySleepingSvg() : getBabyBaseSvg();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve baby Blobbi SVG content
|
||||
*/
|
||||
export function resolveBabySvg(blobbi: Blobbi, options: BabySvgResolverOptions = {}): string {
|
||||
const { isSleeping = false } = options;
|
||||
|
||||
if (blobbi.lifeStage !== 'baby') {
|
||||
console.warn('resolveBabySvg called with non-baby Blobbi');
|
||||
return getFallbackBabySvg();
|
||||
}
|
||||
|
||||
return isSleeping ? getBabySleepingSvg() : getBabyBaseSvg();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload baby SVGs for quick switching
|
||||
*/
|
||||
export function preloadBabySvgs(): void {
|
||||
// Both SVGs are inlined constants — this function exists for API consistency
|
||||
// This function exists for API consistency
|
||||
getBabyBaseSvg();
|
||||
getBabySleepingSvg();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback baby SVG content
|
||||
*/
|
||||
function getFallbackBabySvg(): string {
|
||||
return `
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="fallbackBodyGradient" cx="0.3" cy="0.25">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6"/>
|
||||
<stop offset="60%" style="stop-color:#7c3aed"/>
|
||||
<stop offset="100%" style="stop-color:#6d28d9"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
|
||||
fill="url(#fallbackBodyGradient)" />
|
||||
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
|
||||
<ellipse cx="38" cy="45" rx="8" ry="10" fill="#fff" />
|
||||
<ellipse cx="62" cy="45" rx="8" ry="10" fill="#fff" />
|
||||
<circle cx="38" cy="46" r="6" fill="#374151" />
|
||||
<circle cx="62" cy="46" r="6" fill="#374151" />
|
||||
<circle cx="40" cy="44" r="2" fill="white" />
|
||||
<circle cx="64" cy="44" r="2" fill="white" />
|
||||
<path d="M 42 62 Q 50 68 58 62" stroke="#374151" stroke-width="2.5" fill="none" stroke-linecap="round" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||