Update package dependencies and enhance application structure

- Added several new dependencies related to the Applesauce library, including 'applesauce-accounts', 'applesauce-common', 'applesauce-core', 'applesauce-loaders', 'applesauce-relay', and 'applesauce-signers', all at version 5.1.0.
- Updated the development script in package.json to specify a port for Vite and added new seed scripts for profiles and activity.
- Removed outdated image files from the public directory to clean up unused assets.
- Enhanced the App.vue structure by integrating shared components like AppHeader and AuthModal for improved user experience.
- Refactored ContentDetailModal and MobileNav components to support new features and improve usability.

These changes improve the overall functionality and maintainability of the application while ensuring it utilizes the latest libraries for better performance.
This commit is contained in:
Dorian
2026-02-12 12:24:58 +00:00
parent c970f5b29f
commit 725896673c
42 changed files with 3767 additions and 1329 deletions

589
package-lock.json generated
View File

@@ -9,6 +9,12 @@
"version": "0.1.0",
"dependencies": {
"@tanstack/vue-query": "^5.92.9",
"applesauce-accounts": "^5.1.0",
"applesauce-common": "^5.1.0",
"applesauce-core": "^5.1.0",
"applesauce-loaders": "^5.1.0",
"applesauce-relay": "^5.1.0",
"applesauce-signers": "^5.1.0",
"axios": "^1.13.5",
"nostr-tools": "^2.23.0",
"pinia": "^3.0.4",
@@ -23,6 +29,7 @@
"postcss": "^8.5.6",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.18",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.2",
"vite-plugin-pwa": "^1.2.0",
@@ -2664,6 +2671,18 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/secp256k1": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz",
"integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3629,6 +3648,491 @@
"node": ">= 8"
}
},
"node_modules/applesauce-accounts": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/applesauce-accounts/-/applesauce-accounts-5.1.0.tgz",
"integrity": "sha512-xFNqofcQx+GjLbJKqkhEGWPMKPA0Y523ZiLuPwiLxwxiQRN053Rsq+uOTV0qmg/Hspylg3EGR49wwCIE8WnNGg==",
"license": "MIT",
"dependencies": {
"applesauce-core": "^5.1.0",
"applesauce-signers": "^5.1.0",
"nanoid": "^5.1.5",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-accounts/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/applesauce-common": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/applesauce-common/-/applesauce-common-5.1.0.tgz",
"integrity": "sha512-qUEJibEtawkCgrxoyj0u+ECk3C/PKwGSmOq9kCupGjHVG21BU6Nh29BxU4vcmmBXH2ByiA6VuZgyJZb3t2JJuA==",
"license": "MIT",
"dependencies": {
"@scure/base": "^1.2.4",
"applesauce-core": "^5.1.0",
"hash-sum": "^2.0.0",
"light-bolt11-decoder": "^3.2.0",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-common/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.1.0.tgz",
"integrity": "sha512-kk4nHndK4zjS8Sa6mC8LGtQ0LDSP4hlCGPJ9lpyIln7MkZaNFWD9eFd+fsEhfE9kyrne9IyYuVfJNp+EqY1b9w==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"fast-deep-equal": "^3.1.3",
"hash-sum": "^2.0.0",
"nanoid": "^5.0.9",
"nostr-tools": "~2.19",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-core/node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/applesauce-core/node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core/node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core/node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "~1.3.0",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/applesauce-core/node_modules/nostr-tools": {
"version": "2.19.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/applesauce-loaders": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/applesauce-loaders/-/applesauce-loaders-5.1.0.tgz",
"integrity": "sha512-xllWYl7KxG0oaJqKVdZNzxN8OZQoDMaMmaTAO9Ao1Son+mmJyR9Q4UVicSwSlzzHarf59WCfvJeSdrSHIitkHg==",
"license": "MIT",
"dependencies": {
"applesauce-core": "^5.1.0",
"nanoid": "^5.0.9",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-loaders/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/applesauce-relay": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-5.1.0.tgz",
"integrity": "sha512-d0LTJmQmr5gsYFm9A6efPEo2Bx/ewoL7LNsIdieMx34QohZBpPb137RvU9KQ1lFIXTm0tudd8VYfAPncqti2OQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.7.1",
"applesauce-core": "^5.1.0",
"nanoid": "^5.0.9",
"nostr-tools": "~2.19",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-relay/node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/applesauce-relay/node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "~1.3.0",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-relay/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/applesauce-relay/node_modules/nostr-tools": {
"version": "2.19.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/applesauce-relay/node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-signers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/applesauce-signers/-/applesauce-signers-5.1.0.tgz",
"integrity": "sha512-sdQe6J1txYV1GVX8/zSGZDyyXuuZomePHSfUDozZmNAnXhCXE0wqVfhLK0yegVMnomSgoeDUCsGmJiTE2BHqoQ==",
"license": "MIT",
"dependencies": {
"@noble/secp256k1": "^1.7.1",
"applesauce-core": "^5.1.0",
"debug": "^4.4.0",
"nanoid": "^5.0.9",
"rxjs": "^7.8.2"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-signers/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -4226,7 +4730,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4586,7 +5089,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -4925,6 +5427,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
@@ -5067,6 +5582,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash-sum": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
"license": "MIT"
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -5713,6 +6234,27 @@
"node": ">=6"
}
},
"node_modules/light-bolt11-decoder": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz",
"integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==",
"license": "MIT",
"dependencies": {
"@scure/base": "1.1.1"
}
},
"node_modules/light-bolt11-decoder/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -5863,7 +6405,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/muggle-string": {
@@ -6550,6 +7091,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -6636,6 +7187,15 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -7497,9 +8057,28 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-fest": {
"version": "0.16.0",

View File

@@ -4,13 +4,23 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --port 5174",
"start": "bash scripts/start.sh",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
"type-check": "vue-tsc --noEmit",
"seed:profiles": "npx tsx scripts/seed-profiles.ts",
"seed:activity": "npx tsx scripts/seed-activity.ts",
"seed": "npx tsx scripts/seed-profiles.ts && npx tsx scripts/seed-activity.ts"
},
"dependencies": {
"@tanstack/vue-query": "^5.92.9",
"applesauce-accounts": "^5.1.0",
"applesauce-common": "^5.1.0",
"applesauce-core": "^5.1.0",
"applesauce-loaders": "^5.1.0",
"applesauce-relay": "^5.1.0",
"applesauce-signers": "^5.1.0",
"axios": "^1.13.5",
"nostr-tools": "^2.23.0",
"pinia": "^3.0.4",
@@ -25,6 +35,7 @@
"postcss": "^8.5.6",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.18",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.2",
"vite-plugin-pwa": "^1.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

340
scripts/seed-activity.ts Normal file
View File

@@ -0,0 +1,340 @@
/**
* Seeds the local relay with reactions (kind 17) and comments (kind 1111)
* for all IndeeHub content, so the UI has real data to display.
*
* Run after seed-profiles.ts and with the relay already running.
*/
import { Relay } from 'applesauce-relay'
import { PrivateKeySigner } from 'applesauce-signers/signers/private-key-signer'
import {
TEST_PERSONAS,
TASTEMAKER_PERSONAS,
} from '../src/data/testPersonas.js'
const RELAY_URL = 'ws://localhost:7777'
const ORIGIN = 'http://localhost:5174'
// ── Content catalog (matching src/data/indeeHubFilms.ts) ──────────
const CONTENT = [
{ id: 'god-bless-bitcoin', title: 'God Bless Bitcoin' },
{ id: 'thethingswecarry', title: 'The Things We Carry' },
{ id: 'duel', title: 'Duel' },
{ id: '2b0d7349-c010-47a0-b584-49e1bf86ab2f', title: 'Hard Money' },
{ id: '665a4095-73b9-480d-a0a4-b2aafaf2bce4', title: 'Bitcoiners' },
{ id: '3c113b66-3bb5-4cac-90eb-965ecedc4aa2', title: 'Lekker Feeling' },
{ id: 'stranded', title: 'STRANDED' },
{ id: 'bbdb0178-0b96-4ab5-addf-ba1f029c1cb3', title: 'The Housing Bubble' },
{ id: '584f310b-2269-4b05-a09d-261a0a3c1f78', title: 'Menger' },
{ id: 'ef92cd99-7188-4c48-b4bf-0b31fdd8934e', title: 'Everybody Does It' },
{ id: 'e1bd64d6-63c9-4c91-8d91-c69f5376286e', title: 'Gods of Their Own Religion' },
{ id: 'forgingacountry', title: 'Forging a Country' },
{ id: 'home', title: 'HOME' },
{ id: 'e1f58162-9288-418e-803d-196dcde00782', title: 'Kismet' },
{ id: 'identity-theft', title: 'Identity Theft' },
{ id: 'comingto', title: 'Coming To' },
{ id: 'down-the-pch', title: 'Down the P.C.H.' },
{ id: '0cb9de15-566d-4130-b80c-d42e952bb803', title: 'Breaking Up Is Hard to Do' },
{ id: '24b6f7c6-8f56-40f2-831a-54f40b03c427', title: 'The Florist' },
{ id: '311f772f-6559-4982-8918-d0f4be9e1b76', title: 'Plastic Money' },
{ id: '5bd753b7-9ff1-4966-a1c4-b3b93c62ed5d', title: 'Time Traveling Thieves' },
{ id: '34f042bd-23d6-40f4-9707-4b3bb62fdd58', title: 'Little Billy' },
]
// ── helpers ──────────────────────────────────────────────────────
type Persona = { name: string; nsec: string; pubkey: string }
function contentUrl(contentId: string): string {
return `${ORIGIN}/content/${contentId}`
}
function pick<T>(arr: T[], n: number): T[] {
const shuffled = [...arr].sort(() => Math.random() - 0.5)
return shuffled.slice(0, n)
}
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
// ── personas ────────────────────────────────────────────────────
const allPersonas: Persona[] = [
...(TEST_PERSONAS as unknown as Persona[]),
...(TASTEMAKER_PERSONAS as unknown as Persona[]),
]
const tastemakers: Persona[] = TASTEMAKER_PERSONAS as unknown as Persona[]
const now = Math.floor(Date.now() / 1000)
const ONE_DAY = 86400
const ONE_WEEK = 7 * ONE_DAY
// Content subsets for different activity patterns
const topContent = CONTENT.slice(0, 8)
const midContent = CONTENT.slice(8, 16)
const trendingContent = pick(CONTENT.slice(0, 12), 5)
const tastemakerFaves = pick(CONTENT.slice(0, 10), 6)
// ── sample comments ─────────────────────────────────────────────
const POSITIVE_COMMENTS = [
'Absolutely incredible film. A masterpiece in every sense.',
'This movie changed my perspective on cinema. Must watch!',
'The cinematography alone is worth the price of admission.',
'One of the greatest performances I\'ve ever seen on screen.',
'Every frame is a painting. Stunning work.',
'I\'ve seen this at least 5 times and it gets better every watch.',
'The screenplay is tight, the pacing is perfect.',
'A landmark achievement in filmmaking.',
'This deserves every award it got and more.',
'Rewatched it last night — still holds up beautifully.',
'Such an important documentary. Everyone should see this.',
'The storytelling here is on another level.',
]
const MIXED_COMMENTS = [
'Good but I think it\'s a bit overrated honestly.',
'Solid film, though the third act drags a little.',
'Worth watching once for sure, but I wouldn\'t rewatch.',
'Technically impressive but emotionally I felt nothing.',
'The hype is a bit much, but it\'s still a decent movie.',
'Some great moments, but also some really slow stretches.',
'I can see why people love it, just not my cup of tea.',
'Better than I expected, worse than the reviews suggest.',
]
const NEGATIVE_COMMENTS = [
'I really don\'t understand the hype around this one.',
'Couldn\'t finish it. Way too slow for my taste.',
'Overrated. There are much better films in this genre.',
]
// ── publishing helper ───────────────────────────────────────────
async function publishEvent(
relay: Relay,
signer: PrivateKeySigner,
event: { kind: number; content: string; tags: string[][]; created_at: number },
label: string,
): Promise<boolean> {
const signed = await signer.signEvent(event)
try {
const res = await relay.publish(signed, { timeout: 5000 })
if (!res.ok) console.warn(`${label}: ${res.message}`)
return true
} catch (err) {
console.error(`${label}:`, err instanceof Error ? err.message : err)
return false
}
}
// ── seed reactions (kind 17) ────────────────────────────────────
async function seedReactions(relay: Relay) {
console.log('\n📊 Seeding reactions (kind 17)...')
let count = 0
// Top content: lots of positive reactions
for (const item of topContent) {
const voters = pick(allPersonas, randomInt(5, 10))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const emoji = Math.random() < 0.9 ? '+' : '-'
const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: emoji,
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Mid content: moderate reactions, more mixed
for (const item of pick(midContent, 5)) {
const voters = pick(allPersonas, randomInt(2, 5))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const emoji = Math.random() < 0.6 ? '+' : '-'
const age = randomInt(2 * ONE_DAY, 60 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: emoji,
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Trending content: recent reactions
for (const item of trendingContent) {
const voters = pick(allPersonas, randomInt(4, 8))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const age = randomInt(0, ONE_WEEK)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: '+',
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `trending-reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Tastemaker-specific reactions
for (const item of tastemakerFaves) {
const voters = pick(tastemakers, randomInt(2, 5))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const age = randomInt(0, 14 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: '+',
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `tastemaker-reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
console.log(`${count} reactions seeded`)
}
// ── seed comments (kind 1111) ───────────────────────────────────
async function seedComments(relay: Relay) {
console.log('\n💬 Seeding comments (kind 1111)...')
let count = 0
// Top content: several comments
for (const item of topContent) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(2, 5))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const comments =
Math.random() < 0.7 ? POSITIVE_COMMENTS : MIXED_COMMENTS
const content = comments[randomInt(0, comments.length - 1)]
const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Mid content: occasional comments
for (const item of pick(midContent, 4)) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(1, 2))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const pool = [...MIXED_COMMENTS, ...NEGATIVE_COMMENTS]
const content = pool[randomInt(0, pool.length - 1)]
const age = randomInt(3 * ONE_DAY, 45 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Tastemaker reviews on their faves
for (const item of tastemakerFaves.slice(0, 4)) {
const url = contentUrl(item.id)
const reviewers = pick(tastemakers, randomInt(1, 3))
for (const persona of reviewers) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const content = POSITIVE_COMMENTS[randomInt(0, POSITIVE_COMMENTS.length - 1)]
const age = randomInt(0, 10 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `tastemaker-comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Trending content: recent comments
for (const item of trendingContent.slice(0, 3)) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(2, 4))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const content = POSITIVE_COMMENTS[randomInt(0, POSITIVE_COMMENTS.length - 1)]
const age = randomInt(0, 3 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `trending-comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
console.log(`${count} comments seeded`)
}
// ── main ────────────────────────────────────────────────────────
async function main() {
console.log('🎬 Seeding activity data into relay at', RELAY_URL)
const relay = new Relay(RELAY_URL)
await seedReactions(relay)
await seedComments(relay)
console.log('\n✅ Done! Activity seeded successfully.')
setTimeout(() => process.exit(0), 1000)
}
main().catch((err) => {
console.error('Fatal:', err)
process.exit(1)
})

63
scripts/seed-profiles.ts Normal file
View File

@@ -0,0 +1,63 @@
import { Relay } from 'applesauce-relay'
import { PrivateKeySigner } from 'applesauce-signers/signers/private-key-signer'
import {
TEST_PERSONAS,
TASTEMAKER_PERSONAS,
} from '../src/data/testPersonas.js'
const RELAY_URL = 'ws://localhost:7777'
type Persona = { name: string; nsec: string; pubkey: string }
function buildProfile(persona: Persona, role: 'test' | 'tastemaker') {
const about =
role === 'tastemaker'
? `${persona.name} — tastemaker for IndeeHub`
: `${persona.name} — test persona for IndeeHub`
return {
name: persona.name,
display_name: persona.name,
about,
picture: `https://robohash.org/${persona.pubkey}.png`,
bot: true,
}
}
async function seedProfiles() {
const relay = new Relay(RELAY_URL)
const allPersonas: { persona: Persona; role: 'test' | 'tastemaker' }[] = [
...TEST_PERSONAS.map((p) => ({ persona: p as Persona, role: 'test' as const })),
...TASTEMAKER_PERSONAS.map((p) => ({ persona: p as Persona, role: 'tastemaker' as const })),
]
const now = Math.floor(Date.now() / 1000)
for (const { persona, role } of allPersonas) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const profile = buildProfile(persona, role)
const signed = await signer.signEvent({
kind: 0,
created_at: now,
tags: [],
content: JSON.stringify(profile),
})
try {
const res = await relay.publish(signed, { timeout: 5000 })
console.log(`${persona.name} (${role}): ${res.ok ? 'OK' : res.message}`)
} catch (err) {
console.error(`${persona.name} (${role}):`, err)
}
}
// Give relay time to flush, then exit
setTimeout(() => process.exit(0), 1000)
}
seedProfiles().catch((err) => {
console.error('Fatal:', err)
process.exit(1)
})

75
scripts/start.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -e
RELAY_PORT=7777
RELAY_URL="ws://localhost:$RELAY_PORT"
VITE_PORT=5174
cleanup() {
echo ""
echo "Shutting down..."
# Kill background jobs (relay)
kill $(jobs -p) 2>/dev/null
exit 0
}
trap cleanup SIGINT SIGTERM
# Check that nak is installed
if ! command -v nak &>/dev/null; then
echo "Error: 'nak' is not installed."
echo "Install with: brew install nak"
exit 1
fi
# Kill any existing process on the relay port
if lsof -i :$RELAY_PORT -P &>/dev/null; then
echo "Port $RELAY_PORT is already in use, killing existing process..."
lsof -ti :$RELAY_PORT | xargs kill -9 2>/dev/null
sleep 1
fi
# Start the local relay in the background
echo "Starting local Nostr relay on port $RELAY_PORT..."
nak serve --port $RELAY_PORT &
RELAY_PID=$!
# Wait for the relay to be ready
echo "Waiting for relay to be ready..."
for i in $(seq 1 30); do
if curl -s -o /dev/null http://localhost:$RELAY_PORT 2>/dev/null; then
echo "Relay is ready at $RELAY_URL (pid $RELAY_PID)"
break
fi
if ! kill -0 $RELAY_PID 2>/dev/null; then
echo "Error: relay process died unexpectedly"
exit 1
fi
sleep 0.5
done
# Verify relay is actually responding
if ! curl -s -o /dev/null http://localhost:$RELAY_PORT 2>/dev/null; then
echo "Error: relay did not start in time"
kill $RELAY_PID 2>/dev/null
exit 1
fi
# Seed test profiles and activity
echo ""
echo "Seeding test profiles..."
npx tsx scripts/seed-profiles.ts
echo ""
echo "Seeding activity (reactions & comments)..."
npx tsx scripts/seed-activity.ts
echo ""
# Start the Vite dev server (in foreground so Ctrl+C works)
echo "Starting dev server..."
echo "============================================"
echo " Relay: $RELAY_URL (pid $RELAY_PID)"
echo " App: http://localhost:$VITE_PORT"
echo " Press Ctrl+C to stop everything"
echo "============================================"
echo ""
npx vite --port $VITE_PORT

View File

@@ -1,9 +1,20 @@
<template>
<div id="app" class="min-h-screen">
<RouterView />
<!-- Shared Header -->
<AppHeader @openAuth="showAuthModal = true" />
<!-- Route Content -->
<RouterView @openAuth="showAuthModal = true" />
<!-- Mobile Navigation (hidden on desktop) -->
<MobileNav />
<!-- Auth Modal (shared across all views) -->
<AuthModal
:isOpen="showAuthModal"
@close="showAuthModal = false"
@success="showAuthModal = false"
/>
<!-- Toast Notifications -->
<ToastContainer />
@@ -11,12 +22,15 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useAuthStore } from './stores/auth'
import AppHeader from './components/AppHeader.vue'
import AuthModal from './components/AuthModal.vue'
import MobileNav from './components/MobileNav.vue'
import ToastContainer from './components/ToastContainer.vue'
const authStore = useAuthStore()
const showAuthModal = ref(false)
onMounted(async () => {
// Initialize authentication on app mount

View File

@@ -5,21 +5,74 @@
<!-- Logo + Navigation (Left Side) -->
<div class="flex items-center gap-10">
<router-link to="/">
<img src="/assets/images/logo.svg" alt="IndeedHub" class="h-10 ml-2 md:ml-0" />
<img src="/assets/images/logo-desktop.svg" alt="IndeeHub" class="h-8 md:h-10 ml-2 md:ml-0" />
</router-link>
<!-- Navigation - Desktop -->
<nav v-if="showNav" class="hidden md:flex items-center gap-3">
<router-link to="/" :class="isRoute('/') ? 'nav-button-active' : 'nav-button'">Films</router-link>
<router-link to="/library" :class="isRoute('/library') ? 'nav-button-active' : 'nav-button'">My List</router-link>
<button @click="handleFilmsClick" :class="isRoute('/') && !activeAlgorithm ? 'nav-button-active' : 'nav-button'">Films</button>
<router-link to="/library" :class="isRoute('/library') && !activeAlgorithm ? 'nav-button-active' : 'nav-button'" @click="clearFilter">My List</router-link>
<!-- Algorithm Filters -->
<button
v-for="algo in algorithms"
:key="algo.id"
@click="setAlgorithm(algo.id)"
:class="activeAlgorithm === algo.id ? 'nav-button-active' : 'nav-button'"
>
{{ algo.label }}
</button>
</nav>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-4">
<!-- Sign In Button (if not authenticated) -->
<!-- Nostr Login (persona switcher or extension) -->
<div v-if="!nostrLoggedIn" class="hidden md:flex items-center gap-2">
<!-- Persona Switcher (dev) -->
<div class="relative persona-dropdown">
<button @click="togglePersonaMenu" class="nav-button px-3 py-2 text-xs">
Persona
<svg class="w-3 h-3 ml-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div v-if="personaMenuOpen" class="profile-menu absolute right-0 mt-2 w-52">
<div class="floating-glass-header py-2 rounded-xl">
<div class="px-3 py-1 text-xs text-white/40 uppercase tracking-wider">Test Personas</div>
<button
v-for="persona in testPersonas"
:key="persona.pubkey"
@click="handlePersonaLogin(persona)"
class="profile-menu-item flex items-center gap-3 px-4 py-2 w-full text-left"
>
<img :src="`https://robohash.org/${persona.pubkey}.png`" class="w-6 h-6 rounded-full" :alt="persona.name" />
<span>{{ persona.name }}</span>
</button>
<div class="border-t border-white/10 my-1"></div>
<div class="px-3 py-1 text-xs text-white/40 uppercase tracking-wider">Tastemakers</div>
<button
v-for="persona in tastemakerPersonas"
:key="persona.pubkey"
@click="handlePersonaLogin(persona)"
class="profile-menu-item flex items-center gap-3 px-4 py-2 w-full text-left"
>
<img :src="`https://robohash.org/${persona.pubkey}.png`" class="w-6 h-6 rounded-full" :alt="persona.name" />
<span>{{ persona.name }}</span>
</button>
</div>
</div>
</div>
<!-- Extension Login -->
<button @click="handleExtensionLogin" class="nav-button px-3 py-2 text-xs">
Extension
</button>
</div>
<!-- Sign In (app auth, if not Nostr logged in) -->
<button
v-if="!isAuthenticated && showAuth"
v-if="!isAuthenticated && showAuth && !nostrLoggedIn"
@click="$emit('openAuth')"
class="hidden md:block hero-play-button px-4 py-2 text-sm"
>
@@ -33,22 +86,24 @@
</svg>
</button>
<!-- Profile Dropdown (authenticated only) -->
<div v-if="isAuthenticated" class="hidden md:block relative profile-dropdown">
<!-- Active Nostr Account -->
<div v-if="nostrLoggedIn" class="hidden md:block relative profile-dropdown">
<button
@click="toggleDropdown"
class="profile-button flex items-center gap-2"
>
<div class="profile-avatar">
<span>{{ userInitials }}</span>
</div>
<span class="text-white text-sm font-medium">{{ userName }}</span>
<img
v-if="nostrActivePubkey"
:src="`https://robohash.org/${nostrActivePubkey}.png`"
class="w-8 h-8 rounded-full"
alt="Avatar"
/>
<span class="text-white text-sm font-medium">{{ nostrActiveName }}</span>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': dropdownOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Dropdown Menu -->
<div v-if="dropdownOpen" class="profile-menu absolute right-0 mt-2 w-48">
<div class="floating-glass-header py-2 rounded-xl">
<button @click="navigateTo('/profile')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
@@ -74,12 +129,31 @@
</div>
</div>
<!-- Mobile User Avatar + Name -->
<!-- Fallback: App-auth profile (when Nostr not logged in but app auth is) -->
<div v-else-if="isAuthenticated" class="hidden md:block relative profile-dropdown">
<button @click="toggleDropdown" class="profile-button flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center text-xs font-bold text-white">
{{ userInitials }}
</div>
<span class="text-white text-sm font-medium">{{ userName }}</span>
</button>
</div>
<!-- Mobile -->
<div class="md:hidden flex items-center gap-2 mr-2">
<div v-if="isAuthenticated" class="profile-avatar">
<span>{{ userInitials }}</span>
</div>
<span v-if="isAuthenticated" class="text-white text-sm font-medium">{{ userName }}</span>
<img
v-if="nostrLoggedIn && nostrActivePubkey"
:src="`https://robohash.org/${nostrActivePubkey}.png`"
class="w-7 h-7 rounded-full"
alt="Avatar"
/>
<span v-if="nostrLoggedIn" class="text-white text-sm font-medium">{{ nostrActiveName }}</span>
<template v-else-if="isAuthenticated">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center text-xs font-bold text-white">
{{ userInitials }}
</div>
<span class="text-white text-sm font-medium">{{ userName }}</span>
</template>
<button v-else-if="showAuth" @click="$emit('openAuth')" class="text-white text-sm font-medium">Sign In</button>
</div>
</div>
@@ -92,6 +166,10 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useContentDiscovery } from '../composables/useContentDiscovery'
type Persona = { name: string; nsec: string; pubkey: string }
interface Props {
showNav?: boolean
@@ -113,11 +191,40 @@ defineEmits<Emits>()
const router = useRouter()
const route = useRoute()
const { user, isAuthenticated, logout } = useAuth()
const { user, isAuthenticated, logout: appLogout } = useAuth()
const {
isLoggedIn: nostrLoggedIn,
activePubkey: nostrActivePubkey,
activeName: nostrActiveName,
testPersonas,
tastemakerPersonas,
loginWithExtension,
loginWithPersona,
logout: nostrLogout,
} = useAccounts()
const {
activeAlgorithm,
algorithms,
setAlgorithm: _setAlgorithm,
} = useContentDiscovery()
/**
* When a filter is clicked, navigate to Films page if not already there,
* then apply the filter.
*/
function setAlgorithm(id: string) {
_setAlgorithm(id as any)
if (route.path !== '/') {
router.push('/')
}
}
const dropdownOpen = ref(false)
const personaMenuOpen = ref(false)
const userInitials = computed(() => {
if (nostrActiveName.value) return nostrActiveName.value[0].toUpperCase()
if (!user.value?.legalName) return 'U'
const names = user.value.legalName.split(' ')
return names.length > 1
@@ -126,6 +233,7 @@ const userInitials = computed(() => {
})
const userName = computed(() => {
if (nostrActiveName.value) return nostrActiveName.value
return user.value?.legalName?.split(' ')[0] || 'Guest'
})
@@ -133,8 +241,31 @@ function isRoute(path: string): boolean {
return route.path === path
}
/** Navigate to Films and clear any active filter */
function handleFilmsClick() {
if (activeAlgorithm.value) {
_setAlgorithm(activeAlgorithm.value as any) // toggle off
}
if (route.path !== '/') {
router.push('/')
}
}
/** Clear active filter (used when navigating to My List) */
function clearFilter() {
if (activeAlgorithm.value) {
_setAlgorithm(activeAlgorithm.value as any) // toggle off
}
}
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
personaMenuOpen.value = false
}
function togglePersonaMenu() {
personaMenuOpen.value = !personaMenuOpen.value
dropdownOpen.value = false
}
function navigateTo(path: string) {
@@ -142,17 +273,31 @@ function navigateTo(path: string) {
router.push(path)
}
async function handlePersonaLogin(persona: Persona) {
personaMenuOpen.value = false
await loginWithPersona(persona)
}
async function handleExtensionLogin() {
await loginWithExtension()
}
async function handleLogout() {
await logout()
nostrLogout()
await appLogout()
dropdownOpen.value = false
router.push('/')
}
const handleClickOutside = (event: MouseEvent) => {
const dropdown = document.querySelector('.profile-dropdown')
const personaDropdown = document.querySelector('.persona-dropdown')
if (dropdown && !dropdown.contains(event.target as Node)) {
dropdownOpen.value = false
}
if (personaDropdown && !personaDropdown.contains(event.target as Node)) {
personaMenuOpen.value = false
}
}
onMounted(() => {

View File

@@ -0,0 +1,317 @@
<template>
<div class="comment-thread" :style="{ marginLeft: depth > 0 ? `${Math.min(depth, 4) * 20}px` : '0' }">
<div class="comment-item" :class="{ 'comment-reply': depth > 0 }">
<div class="flex gap-3">
<!-- Author Avatar (robohash fallback) -->
<img
:src="authorAvatar"
:alt="authorName"
class="w-8 h-8 rounded-full flex-shrink-0 object-cover"
/>
<div class="flex-1 min-w-0">
<!-- Author + Timestamp -->
<div class="flex items-center gap-2 mb-1">
<span class="text-white text-sm font-medium truncate">{{ authorName }}</span>
<span class="text-white/30 text-xs flex-shrink-0">{{ timeAgo }}</span>
</div>
<!-- Comment Content -->
<p class="text-white/70 text-sm leading-relaxed whitespace-pre-wrap">{{ node.event.content }}</p>
<!-- Comment Actions: Reactions + Reply -->
<div class="flex items-center gap-3 mt-2">
<!-- Upvote -->
<button
v-if="isLoggedIn"
@click="handleReact(true)"
:disabled="hasVoted"
class="comment-action-btn"
:class="{ 'comment-action-active': userVote === '+' }"
:style="{ opacity: userVote === '+' ? 1 : hasVoted ? 0.4 : 1 }"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
<span v-if="reactionCounts.positive > 0">{{ reactionCounts.positive }}</span>
</button>
<!-- Downvote -->
<button
v-if="isLoggedIn"
@click="handleReact(false)"
:disabled="hasVoted"
class="comment-action-btn"
:class="{ 'comment-action-active': userVote === '-' }"
:style="{ opacity: userVote === '-' ? 1 : hasVoted ? 0.4 : 1 }"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
<span v-if="reactionCounts.negative > 0">{{ reactionCounts.negative }}</span>
</button>
<!-- Reply -->
<button
v-if="isLoggedIn"
@click="showReplyForm = !showReplyForm"
class="comment-action-btn"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
<span>Reply</span>
</button>
<!-- Expand/Collapse Replies -->
<button
v-if="node.replies.length > 0"
@click="showReplies = !showReplies"
class="comment-action-btn"
>
<svg class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': showReplies }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
<span>{{ showReplies ? 'Hide' : `${node.replies.length}` }} {{ node.replies.length === 1 ? 'reply' : 'replies' }}</span>
</button>
</div>
<!-- Reply Form -->
<div v-if="showReplyForm" class="mt-3">
<div class="flex gap-2">
<img
:src="currentUserAvatar"
class="w-6 h-6 rounded-full flex-shrink-0 object-cover"
alt="You"
/>
<div class="flex-1">
<textarea
v-model="replyText"
placeholder="Write a reply..."
class="comment-textarea text-sm"
rows="2"
@keydown.meta.enter="submitReply"
@keydown.ctrl.enter="submitReply"
></textarea>
<div class="flex items-center gap-2 justify-end mt-1.5">
<button @click="showReplyForm = false" class="cancel-btn">Cancel</button>
<button
@click="submitReply"
:disabled="!replyText.trim() || isPostingReply"
class="submit-comment-btn text-xs"
:class="{ 'opacity-40 cursor-not-allowed': !replyText.trim() || isPostingReply }"
>
{{ isPostingReply ? '...' : 'Reply' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Nested Replies -->
<template v-if="showReplies && node.replies.length > 0">
<CommentNode
v-for="reply in node.replies"
:key="reply.event.id"
:node="reply"
:depth="depth + 1"
:nostr="nostr"
:is-logged-in="isLoggedIn"
:current-user-avatar="currentUserAvatar"
@get-profile="(pubkey: string) => $emit('getProfile', pubkey)"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { CommentNode as CommentNodeType } from '../composables/useNostr'
interface Props {
node: CommentNodeType
depth: number
nostr: any
isLoggedIn: boolean
currentUserAvatar: string
}
const props = defineProps<Props>()
defineEmits<{
'getProfile': [pubkey: string]
}>()
const showReplyForm = ref(false)
const showReplies = ref(props.depth < 2) // Auto-expand first 2 levels
const replyText = ref('')
const isPostingReply = ref(false)
const isReacting = ref(false)
// Profile data
const profile = computed(() => props.nostr.profiles.value.get(props.node.event.pubkey))
const authorName = computed(() => {
return profile.value?.name || profile.value?.display_name || 'Anonymous'
})
const authorAvatar = computed(() => {
if (profile.value?.picture) return profile.value.picture
return `https://robohash.org/${props.node.event.pubkey}.png`
})
// Time formatting
const timeAgo = computed(() => {
const now = Math.floor(Date.now() / 1000)
const diff = now - props.node.event.created_at
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
return new Date(props.node.event.created_at * 1000).toLocaleDateString()
})
// Per-comment reactions
const reactionCounts = computed(() => {
return props.nostr.getCommentReactionCounts(props.node.event.id)
})
const userVote = computed(() => {
return props.nostr.getUserCommentReaction(props.node.event.id)
})
const hasVoted = computed(() => {
return props.nostr.hasVotedOnComment(props.node.event.id)
})
async function handleReact(positive: boolean) {
if (isReacting.value || hasVoted.value) return
isReacting.value = true
try {
await props.nostr.reactToComment(props.node.event, positive)
} catch (err) {
console.error('Failed to react to comment:', err)
} finally {
isReacting.value = false
}
}
async function submitReply() {
if (!replyText.value.trim() || isPostingReply.value) return
isPostingReply.value = true
try {
await props.nostr.postReply(props.node.event, replyText.value.trim())
replyText.value = ''
showReplyForm.value = false
showReplies.value = true
} catch (err) {
console.error('Failed to post reply:', err)
} finally {
isPostingReply.value = false
}
}
</script>
<style scoped>
.comment-item {
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.comment-item:last-child {
border-bottom: none;
}
.comment-reply {
border-left: 2px solid rgba(255, 255, 255, 0.08);
padding-left: 12px;
margin-top: 4px;
}
.comment-action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.45);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.comment-action-btn:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.06);
}
.comment-action-btn:disabled {
cursor: default;
}
.comment-action-active {
color: rgba(255, 255, 255, 0.9);
}
.comment-textarea {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 10px 12px;
color: white;
font-size: 13px;
resize: none;
outline: none;
transition: border-color 0.2s ease;
}
.comment-textarea::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.comment-textarea:focus {
border-color: rgba(255, 255, 255, 0.25);
}
.submit-comment-btn {
padding: 5px 14px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
background: rgba(255, 255, 255, 0.85);
color: rgba(0, 0, 0, 0.9);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.submit-comment-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
}
.cancel-btn {
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-btn:hover {
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.2);
}
</style>

View File

@@ -56,19 +56,31 @@
</button>
<!-- Like Button -->
<button @click="handleLike" class="action-btn" :class="{ 'action-btn-active': userReaction === '+' }">
<button
@click="handleLike"
:disabled="hasVoted || !isNostrLoggedIn"
class="action-btn"
:class="{ 'action-btn-active': userReaction === '+' }"
:style="{ opacity: userReaction === '+' ? 1 : hasVoted ? 0.4 : 1 }"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
</svg>
<span v-if="reactionCounts.positive > 0" class="text-xs">{{ reactionCounts.positive }}</span>
<span class="text-xs">{{ reactionCounts.positive }}</span>
</button>
<!-- Dislike Button -->
<button @click="handleDislike" class="action-btn" :class="{ 'action-btn-active': userReaction === '-' }">
<button
@click="handleDislike"
:disabled="hasVoted || !isNostrLoggedIn"
class="action-btn"
:class="{ 'action-btn-active': userReaction === '-' }"
:style="{ opacity: userReaction === '-' ? 1 : hasVoted ? 0.4 : 1 }"
>
<svg class="w-5 h-5 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
</svg>
<span v-if="reactionCounts.negative > 0" class="text-xs">{{ reactionCounts.negative }}</span>
<span class="text-xs">{{ reactionCounts.negative }}</span>
</button>
<!-- Share Button -->
@@ -113,16 +125,18 @@
<div class="comments-section">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
Comments
<span class="text-sm font-normal text-white/50">({{ comments.length }})</span>
<span v-if="isDev" class="text-xs bg-white/10 text-white/40 px-2 py-0.5 rounded-full ml-auto">Demo Mode</span>
<span class="text-sm font-normal text-white/50">({{ commentCount }})</span>
<span v-if="!relayConnected" class="text-xs bg-orange-500/20 text-orange-300/60 px-2 py-0.5 rounded-full ml-auto">Relay Offline</span>
</h3>
<!-- Comment Input -->
<div v-if="isAuthenticated" class="comment-input-wrap mb-6">
<div v-if="isNostrLoggedIn" class="comment-input-wrap mb-6">
<div class="flex gap-3">
<div class="profile-avatar flex-shrink-0">
<span>{{ userInitials }}</span>
</div>
<img
:src="currentUserAvatar"
class="w-8 h-8 rounded-full flex-shrink-0 object-cover"
alt="You"
/>
<div class="flex-1">
<textarea
v-model="newComment"
@@ -135,11 +149,11 @@
<div class="flex justify-end mt-2">
<button
@click="submitComment"
:disabled="!newComment.trim()"
:disabled="!newComment.trim() || isPostingComment"
class="submit-comment-btn"
:class="{ 'opacity-40 cursor-not-allowed': !newComment.trim() }"
:class="{ 'opacity-40 cursor-not-allowed': !newComment.trim() || isPostingComment }"
>
Post
{{ isPostingComment ? 'Posting...' : 'Post' }}
</button>
</div>
</div>
@@ -159,41 +173,22 @@
<div class="text-white/40 text-sm">Loading comments...</div>
</div>
<div v-else-if="comments.length === 0" class="text-center py-8">
<div v-else-if="commentTree.length === 0" class="text-center py-8">
<div class="text-white/40 text-sm">No comments yet. Be the first!</div>
</div>
<div v-else class="space-y-4">
<div
v-for="comment in comments"
:key="comment.id"
class="comment-item"
>
<div class="flex gap-3">
<!-- Author Avatar -->
<img
v-if="getProfile(comment.pubkey)?.picture"
:src="getProfile(comment.pubkey).picture"
:alt="getProfile(comment.pubkey)?.name || 'User'"
class="w-8 h-8 rounded-full flex-shrink-0 object-cover"
/>
<div v-else class="w-8 h-8 rounded-full flex-shrink-0 bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center text-xs font-bold text-white">
{{ (getProfile(comment.pubkey)?.name || 'A')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-white text-sm font-medium truncate">
{{ getProfile(comment.pubkey)?.name || 'Anonymous' }}
</span>
<span class="text-white/30 text-xs flex-shrink-0">
{{ formatTimeAgo(comment.created_at) }}
</span>
</div>
<p class="text-white/70 text-sm leading-relaxed">{{ comment.content }}</p>
</div>
</div>
</div>
<!-- Threaded comments -->
<div v-else class="space-y-1">
<template v-for="node in commentTree" :key="node.event.id">
<CommentNode
:node="node"
:depth="0"
:nostr="nostr"
:is-logged-in="isNostrLoggedIn"
:current-user-avatar="currentUserAvatar"
@get-profile="getProfile"
/>
</template>
</div>
</div>
</div>
@@ -227,11 +222,13 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useNostr } from '../composables/useNostr'
import type { Content } from '../types/content'
import VideoPlayer from './VideoPlayer.vue'
import SubscriptionModal from './SubscriptionModal.vue'
import RentalModal from './RentalModal.vue'
import CommentNode from './CommentNode.vue'
interface Props {
isOpen: boolean
@@ -246,51 +243,52 @@ interface Emits {
const props = defineProps<Props>()
defineEmits<Emits>()
const { isAuthenticated, hasActiveSubscription, user } = useAuth()
const { isAuthenticated, hasActiveSubscription } = useAuth()
const { isLoggedIn: isNostrLoggedIn, activePubkey } = useAccounts()
const isDev = import.meta.env.DEV
const newComment = ref('')
const isPostingComment = ref(false)
const isInMyList = ref(false)
const userReaction = ref<string | null>(null)
const showVideoPlayer = ref(false)
const showSubscriptionModal = ref(false)
const showRentalModal = ref(false)
const relayConnected = ref(true)
// Nostr social data -- initialized per content
// Nostr social data -- subscribes to relay in real time
const nostr = useNostr()
const comments = computed(() => nostr.comments.value)
const commentTree = computed(() => nostr.commentTree.value)
const reactionCounts = computed(() => nostr.reactionCounts.value)
const isLoadingComments = computed(() => nostr.isLoading.value)
const commentCount = computed(() => nostr.commentCount.value)
const userInitials = computed(() => {
if (!user.value?.legalName) return 'U'
const names = user.value.legalName.split(' ')
return names.length > 1
? `${names[0][0]}${names[names.length - 1][0]}`
: names[0][0]
// User's existing reaction read from relay (not local state)
const userReaction = computed(() => nostr.userContentReaction.value)
const hasVoted = computed(() => nostr.hasVotedOnContent.value)
// Current user avatar (robohash)
const currentUserAvatar = computed(() => {
if (activePubkey.value) {
return `https://robohash.org/${activePubkey.value}.png`
}
return 'https://robohash.org/anonymous.png'
})
// Fetch social data when content changes
watch(() => props.content?.id, async (newId) => {
// Subscribe to Nostr data when content changes
watch(() => props.content?.id, (newId) => {
if (newId && props.isOpen) {
await loadSocialData(newId)
loadSocialData(newId)
}
})
watch(() => props.isOpen, async (open) => {
watch(() => props.isOpen, (open) => {
if (open && props.content?.id) {
await loadSocialData(props.content.id)
loadSocialData(props.content.id)
}
})
async function loadSocialData(contentId: string) {
userReaction.value = null
await Promise.all([
nostr.fetchComments(contentId),
nostr.fetchReactions(contentId),
])
nostr.subscribeToComments(contentId)
nostr.subscribeToReactions(contentId)
function loadSocialData(contentId: string) {
nostr.cleanup()
nostr.subscribeToContent(contentId)
}
function getProfile(pubkey: string) {
@@ -298,17 +296,13 @@ function getProfile(pubkey: string) {
}
function handlePlay() {
if (!isAuthenticated.value) {
// Will be caught by parent via openAuth emit
return
}
if (!isAuthenticated.value) return
if (hasActiveSubscription.value) {
showVideoPlayer.value = true
return
}
// No subscription -- show rental modal
showRentalModal.value = true
}
@@ -317,8 +311,7 @@ function toggleMyList() {
}
async function handleLike() {
if (!isAuthenticated.value) return
userReaction.value = userReaction.value === '+' ? null : '+'
if (!isNostrLoggedIn.value || hasVoted.value) return
if (props.content?.id) {
try {
await nostr.postReaction(true, props.content.id)
@@ -329,8 +322,7 @@ async function handleLike() {
}
async function handleDislike() {
if (!isAuthenticated.value) return
userReaction.value = userReaction.value === '-' ? null : '-'
if (!isNostrLoggedIn.value || hasVoted.value) return
if (props.content?.id) {
try {
await nostr.postReaction(false, props.content.id)
@@ -356,13 +348,16 @@ function handleShare() {
}
async function submitComment() {
if (!newComment.value.trim() || !props.content?.id) return
if (!newComment.value.trim() || !props.content?.id || isPostingComment.value) return
isPostingComment.value = true
try {
await nostr.postComment(newComment.value.trim(), props.content.id)
newComment.value = ''
} catch (err) {
console.error('Failed to post comment:', err)
} finally {
isPostingComment.value = false
}
}
@@ -379,17 +374,6 @@ function openSubscriptionFromRental() {
showRentalModal.value = false
showSubscriptionModal.value = true
}
function formatTimeAgo(timestamp: number): string {
const now = Math.floor(Date.now() / 1000)
const diff = now - timestamp
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
return new Date(timestamp * 1000).toLocaleDateString()
}
</script>
<style scoped>
@@ -589,15 +573,6 @@ function formatTimeAgo(timestamp: number): string {
transform: translateY(-1px);
}
.comment-item {
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.comment-item:last-child {
border-bottom: none;
}
/* Transitions */
.modal-fade-enter-active {
transition: opacity 0.3s ease;

View File

@@ -70,7 +70,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { getMockReactionCounts, getMockCommentCount } from '../data/mockSocialData'
import type { Content } from '../types/content'
interface Props {
@@ -83,12 +82,14 @@ defineEmits<{
'content-click': [content: Content]
}>()
function getReactionCount(contentId: string): number {
return getMockReactionCounts(contentId).positive
// Social counts are now fetched from the relay when the detail modal opens.
// We no longer show mock badges on cards -- the real data lives on the relay.
function getReactionCount(_contentId: string): number {
return 0
}
function getCommentCount(contentId: string): number {
return getMockCommentCount(contentId)
function getCommentCount(_contentId: string): number {
return 0
}
const sliderRef = ref<HTMLElement | null>(null)

View File

@@ -1,16 +1,87 @@
<template>
<!-- Filter Bottom Sheet Overlay -->
<Teleport to="body">
<Transition name="sheet">
<div v-if="showFilterSheet" class="filter-sheet-backdrop" @click.self="showFilterSheet = false">
<div class="filter-sheet">
<div class="filter-sheet-handle"></div>
<h3 class="text-white text-base font-semibold mb-4 text-center tracking-wide">Sort By</h3>
<div class="flex flex-col gap-2">
<button
v-for="algo in algorithms"
:key="algo.id"
@click="selectAlgorithm(algo.id)"
class="filter-option"
:class="{ 'filter-option-active': activeAlgorithm === algo.id }"
>
<span>{{ algo.label }}</span>
<svg
v-if="activeAlgorithm === algo.id"
class="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Tab Bar -->
<nav class="mobile-nav fixed bottom-0 left-0 right-0 z-50 md:hidden pb-4 px-4">
<div class="floating-glass-nav px-4 py-3 rounded-2xl">
<div class="flex items-center justify-around gap-1">
<!-- Films -->
<button
v-for="item in navItems"
:key="item.name"
@click="navigate(item.path)"
@click="handleFilmsClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive(item.path) }"
:class="{ 'nav-tab-active': isOnFilmsPage && !isFilterActive }"
>
<component :is="item.icon" class="w-6 h-6 flex-shrink-0" />
<span class="text-xs font-medium whitespace-nowrap">{{ item.name }}</span>
<svg class="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/>
</svg>
<span class="text-xs font-medium whitespace-nowrap">Films</span>
</button>
<!-- Filters (visible on Films and My List) -->
<button
v-if="isOnFilmsPage || isActive('/library')"
@click="showFilterSheet = true"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isFilterActive || showFilterSheet }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Filters</span>
</button>
<!-- My List -->
<button
@click="handleMyListClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/library') && !isFilterActive }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">My List</span>
</button>
<!-- Profile -->
<button
@click="navigate('/profile')"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/profile') }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Profile</span>
</button>
</div>
</div>
@@ -18,35 +89,23 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { h } from 'vue'
import { useContentDiscovery, type AlgorithmId } from '../composables/useContentDiscovery'
const router = useRouter()
const route = useRoute()
const navItems = [
{
name: 'Home',
path: '/',
icon: () => h('svg', { class: 'w-6 h-6', fill: 'currentColor', viewBox: '0 0 24 24' },
h('path', { d: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z' })
)
},
{
name: 'My List',
path: '/library',
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z' })
)
},
{
name: 'Profile',
path: '/profile',
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' })
)
}
]
const {
activeAlgorithm,
algorithms,
setAlgorithm,
isFilterActive,
} = useContentDiscovery()
const showFilterSheet = ref(false)
const isOnFilmsPage = computed(() => route.path === '/')
const navigate = (path: string) => {
router.push(path)
@@ -55,6 +114,33 @@ const navigate = (path: string) => {
const isActive = (path: string) => {
return route.path === path
}
/**
* Tapping Films clears any active filter and navigates to /
*/
function handleFilmsClick() {
if (isFilterActive.value) {
setAlgorithm(activeAlgorithm.value!) // toggle off (sets to null)
}
navigate('/')
}
/** Navigate to My List and clear any active filter */
function handleMyListClick() {
if (isFilterActive.value) {
setAlgorithm(activeAlgorithm.value!) // toggle off
}
navigate('/library')
}
function selectAlgorithm(algo: AlgorithmId) {
setAlgorithm(algo)
showFilterSheet.value = false
// Navigate to Films page if not already there
if (route.path !== '/') {
router.push('/')
}
}
</script>
<style scoped>
@@ -125,4 +211,95 @@ const isActive = (path: string) => {
pointer-events: none;
z-index: -1;
}
/* --- Filter Bottom Sheet --- */
.filter-sheet-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
}
.filter-sheet {
width: 100%;
max-width: 480px;
padding: 16px 24px 32px;
padding-bottom: calc(env(safe-area-inset-bottom, 0) + 32px);
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(60px);
-webkit-backdrop-filter: blur(60px);
border-radius: 24px 24px 0 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: none;
box-shadow:
0 -20px 60px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.filter-sheet-handle {
width: 40px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
margin: 0 auto 16px;
}
.filter-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 14px 18px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.7);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
}
.filter-option:active {
background: rgba(255, 255, 255, 0.12);
}
.filter-option-active {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 1);
font-weight: 600;
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* Sheet transition */
.sheet-enter-active {
transition: all 0.3s ease-out;
}
.sheet-leave-active {
transition: all 0.25s ease-in;
}
.sheet-enter-from {
opacity: 0;
}
.sheet-enter-from .filter-sheet {
transform: translateY(100%);
}
.sheet-leave-to {
opacity: 0;
}
.sheet-leave-to .filter-sheet {
transform: translateY(100%);
}
</style>

View File

@@ -3,235 +3,146 @@
<div v-if="showSplash" class="splash-screen">
<div class="splash-content">
<svg
width="400"
height="400"
viewBox="0 0 1374 1401"
width="600"
height="320"
viewBox="0 0 2051 1099"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="splash-logo"
>
<!-- Top Left Block -->
<!-- Decorative Diagonal Shape 1 -->
<path
class="logo-stroke logo-block-1"
d="M321.5 2.5V417.5H1.5V2.5H321.5Z"
stroke="url(#paint1_linear_548_65)"
stroke-width="3"
class="logo-stroke deco-stroke-1"
d="M48.7775 647.241L0.239258 364.582L350.626 304.395L634.938 546.554L48.7775 647.241Z"
stroke="url(#splash_paint0)"
stroke-width="6"
fill="transparent"
/>
<path
class="logo-fill logo-fill-1"
d="M321.5 2.5V417.5H1.5V2.5H321.5Z"
fill="url(#paint0_linear_548_65)"
class="logo-fill deco-fill-1"
d="M48.7775 647.241L0.239258 364.582L350.626 304.395L634.938 546.554L48.7775 647.241Z"
fill="url(#splash_paint0)"
/>
<!-- Top Right Block -->
<!-- Decorative Diagonal Shape 2 -->
<path
class="logo-stroke logo-block-2"
d="M1372.5 1.5V416.5H1052.5V1.5H1372.5Z"
stroke="url(#paint3_linear_548_65)"
stroke-width="3"
class="logo-stroke deco-stroke-2"
d="M605.933 260.538L895.592 501.778L1172.25 454.254L877.513 213.887L605.933 260.538Z"
stroke="url(#splash_paint1)"
stroke-width="6"
fill="transparent"
/>
<path
class="logo-fill logo-fill-2"
d="M1372.5 1.5V416.5H1052.5V1.5H1372.5Z"
fill="url(#paint2_linear_548_65)"
class="logo-fill deco-fill-2"
d="M605.933 260.538L895.592 501.778L1172.25 454.254L877.513 213.887L605.933 260.538Z"
fill="url(#splash_paint1)"
/>
<!-- Bottom Left Block -->
<!-- Decorative Diagonal Shape 3 -->
<path
class="logo-stroke logo-block-3"
d="M321.5 984.5V1399.5H1.5V984.5H321.5Z"
stroke="url(#paint5_linear_548_65)"
stroke-width="3"
class="logo-stroke deco-stroke-3"
d="M1123.72 171.595L1415.65 412.445L1694.92 364.474L1397.86 124.504L1123.72 171.595Z"
stroke="url(#splash_paint2)"
stroke-width="6"
fill="transparent"
/>
<path
class="logo-fill logo-fill-3"
d="M321.5 984.5V1399.5H1.5V984.5H321.5Z"
fill="url(#paint4_linear_548_65)"
class="logo-fill deco-fill-3"
d="M1123.72 171.595L1415.65 412.445L1694.92 364.474L1397.86 124.504L1123.72 171.595Z"
fill="url(#splash_paint2)"
/>
<!-- Center Bottom Block (Dark) -->
<!-- Decorative Diagonal Shape 4 -->
<path
class="logo-stroke logo-block-4"
d="M909.5 984.5V1399.5H464.5V984.5H909.5Z"
stroke="url(#paint6_linear_548_65)"
stroke-width="3"
class="logo-stroke deco-stroke-4"
d="M1978.53 24.7578L1646.37 81.8147L1943.39 321.789L2027.06 307.417L1978.53 24.7578Z"
stroke="url(#splash_paint3)"
stroke-width="6"
fill="transparent"
/>
<path
class="logo-fill logo-fill-4"
d="M909.5 984.5V1399.5H464.5V984.5H909.5Z"
fill="#1D1D1D"
class="logo-fill deco-fill-4"
d="M1978.53 24.7578L1646.37 81.8147L1943.39 321.789L2027.06 307.417L1978.53 24.7578Z"
fill="url(#splash_paint3)"
/>
<!-- Bottom Right Block -->
<!-- Text: I -->
<path
class="logo-stroke logo-block-5"
d="M1372.5 983.5V1398.5H1052.5V983.5H1372.5Z"
stroke="url(#paint8_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-5"
d="M1372.5 983.5V1398.5H1052.5V983.5H1372.5Z"
fill="url(#paint7_linear_548_65)"
class="text-letter text-letter-1"
d="M48.9072 1093.3V779.537H121.443V1093.3H48.9072Z"
fill="white"
/>
<!-- Center Diagonals -->
<!-- Text: n -->
<path
class="logo-stroke logo-diagonal logo-diagonal-1"
d="M467.042 558.5L630.433 841.5H469.695L306.305 558.5H467.042Z"
stroke="url(#paint10_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-6"
d="M467.042 558.5L630.433 841.5H469.695L306.305 558.5H467.042Z"
fill="url(#paint9_linear_548_65)"
class="text-letter text-letter-2"
d="M399.801 1093.29H329.106V965.131C329.106 950.034 326.161 938.984 319.901 932.356C314.011 925.36 306.278 922.044 297.073 922.044C290.446 922.044 283.818 923.887 277.19 927.2C270.563 930.516 264.303 935.301 258.78 941.195C253.257 947.085 249.207 954.085 246.261 961.815V1092.92H175.566V863.489H239.266V902.525C245.157 893.319 252.521 885.218 261.358 878.956C270.195 872.698 280.504 867.91 291.919 864.594C303.333 861.281 315.851 859.809 329.106 859.809C344.571 859.809 356.724 862.387 365.929 867.542C375.134 872.698 382.129 879.694 387.284 888.163C392.068 896.635 395.384 906.209 397.223 916.153C399.066 926.465 399.801 936.039 399.801 945.98V1093.29Z"
fill="white"
/>
<!-- Text: d -->
<path
class="logo-stroke logo-diagonal logo-diagonal-2"
d="M832.507 558.5L995.897 841.5H835.092L671.701 558.5H832.507Z"
stroke="url(#paint12_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-7"
d="M832.507 558.5L995.897 841.5H835.092L671.701 558.5H832.507Z"
fill="url(#paint11_linear_548_65)"
class="text-letter text-letter-3"
d="M432.951 978.757C432.951 956.663 437.368 936.775 446.206 918.732C455.044 900.686 467.194 886.691 482.288 876.379C497.386 866.071 515.429 860.544 535.311 860.544C551.511 860.544 566.239 864.228 579.497 871.961C592.752 879.695 602.691 889.637 609.689 902.526V770.689H680.384V1014.48C680.384 1021.48 681.486 1026.63 683.697 1029.58C685.907 1032.53 689.957 1034 695.846 1034.37V1093.29C683.329 1095.5 673.019 1096.97 665.287 1096.97C652.766 1096.97 643.194 1094.39 635.832 1088.87C628.467 1083.71 624.049 1076.35 622.206 1066.77L621.472 1054.99C613.001 1069.35 601.586 1080.03 587.597 1087.39C573.604 1094.39 558.509 1098.08 543.044 1098.08C527.579 1098.08 512.481 1095.13 499.226 1089.24C485.971 1083.34 474.189 1074.88 464.249 1064.2C454.306 1053.52 446.941 1040.99 441.418 1026.63C436.263 1012.27 433.319 996.435 433.319 979.127L432.951 978.757ZM610.057 1006.75V959.241C607.112 951.507 603.062 944.879 597.536 938.985C592.014 933.094 585.754 928.673 579.126 924.99C572.499 921.31 565.504 919.467 558.509 919.467C550.406 919.467 543.411 921.31 536.784 924.623C530.156 927.938 524.634 932.357 520.214 937.88C515.796 943.406 512.114 949.664 509.536 956.663C506.959 963.659 505.854 971.761 505.854 979.862C505.854 987.966 507.329 995.697 509.907 1002.7C512.851 1009.69 516.534 1015.59 521.319 1020.74C526.106 1025.89 531.996 1029.95 538.994 1032.89C545.989 1035.84 553.354 1037.31 561.454 1037.31C566.609 1037.31 571.394 1036.58 576.181 1035.1C580.969 1033.63 585.386 1031.42 589.804 1028.47C594.224 1025.53 597.907 1022.21 601.586 1018.53C605.269 1014.85 608.217 1010.43 610.424 1005.64L610.057 1006.75Z"
fill="white"
/>
<!-- Text: e (first) -->
<path
class="logo-stroke logo-diagonal logo-diagonal-3"
d="M1372.5 558.5V841.5H1200.56L1037.17 558.5H1372.5Z"
stroke="url(#paint14_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-8"
d="M1372.5 558.5V841.5H1200.56L1037.17 558.5H1372.5Z"
fill="url(#paint13_linear_548_65)"
class="text-letter text-letter-4"
d="M846.059 1097.72C826.176 1097.72 808.868 1094.4 793.406 1088.51C777.941 1082.25 765.053 1073.78 754.376 1063.1C743.698 1052.42 735.595 1039.9 729.706 1025.9C724.183 1011.91 721.235 996.811 721.235 981.71C721.235 959.617 726.023 939.361 735.966 921.315C745.905 903.272 759.898 888.54 778.676 877.493C797.456 866.443 819.546 860.92 845.688 860.92C871.831 860.92 894.294 866.443 912.704 877.126C931.114 888.172 945.474 902.535 955.046 920.58C964.619 938.623 969.406 957.774 969.406 978.765C969.406 982.815 969.406 986.866 968.669 990.917C968.301 994.967 967.934 998.283 967.564 1001.23H795.981C796.718 1011.17 799.296 1019.64 804.451 1026.64C809.606 1033.64 815.866 1038.79 823.599 1042.11C831.328 1045.42 839.061 1047.26 847.531 1047.26C858.576 1047.26 868.519 1044.68 877.724 1039.9C886.929 1034.74 893.189 1028.11 896.501 1020.01L956.519 1037.32C950.626 1049.47 942.526 1059.78 931.849 1068.99C921.171 1078.2 908.651 1085.19 893.923 1090.72C879.196 1096.24 863.364 1098.82 845.688 1098.82L846.059 1097.72ZM895.396 957.039C894.661 947.832 891.716 939.361 886.929 932.732C882.141 925.736 876.251 920.21 869.253 916.53C862.258 912.846 853.791 910.636 844.586 910.636C836.116 910.636 828.016 912.479 820.651 916.53C813.289 920.21 807.396 925.736 802.978 932.365C798.558 938.993 795.984 947.462 794.878 957.039H895.396Z"
fill="white"
/>
<!-- Text: e (second) -->
<path
class="logo-stroke logo-diagonal logo-diagonal-4"
d="M101.646 558.5L265.036 841.5H1.5V558.5H101.646Z"
stroke="url(#paint16_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-9"
d="M101.646 558.5L265.036 841.5H1.5V558.5H101.646Z"
fill="url(#paint15_linear_548_65)"
class="text-letter text-letter-5"
d="M1113.02 1097.72C1093.13 1097.72 1075.83 1094.4 1060.36 1088.51C1044.9 1082.25 1032.01 1073.78 1021.33 1063.1C1010.66 1052.42 1002.55 1039.9 996.664 1025.9C991.141 1011.91 988.196 996.811 988.196 981.71C988.196 959.617 992.981 939.361 1002.92 921.315C1012.86 903.272 1026.86 888.54 1045.63 877.493C1064.41 866.443 1086.51 860.92 1112.65 860.92C1138.79 860.92 1161.25 866.443 1179.66 877.126C1198.07 888.172 1212.43 902.535 1222 920.58C1231.58 938.623 1236.36 957.774 1236.36 978.765C1236.36 982.815 1236.36 986.866 1235.63 990.917C1235.26 994.967 1234.89 998.283 1234.52 1001.23H1062.94C1063.68 1011.17 1066.25 1019.64 1071.41 1026.64C1076.56 1033.64 1082.82 1038.79 1090.56 1042.11C1098.29 1045.42 1106.02 1047.26 1114.49 1047.26C1125.53 1047.26 1135.48 1044.68 1144.68 1039.9C1153.89 1034.74 1160.15 1028.11 1163.46 1020.01L1223.48 1037.32C1217.59 1049.47 1209.48 1059.78 1198.81 1068.99C1188.13 1078.2 1175.61 1085.19 1160.88 1090.72C1146.15 1096.24 1130.32 1098.82 1112.65 1098.82L1113.02 1097.72ZM1162.35 957.039C1161.62 947.832 1158.67 939.361 1153.89 932.732C1149.1 925.736 1143.21 920.21 1136.21 916.53C1129.22 912.846 1120.75 910.636 1111.54 910.636C1103.07 910.636 1094.97 912.479 1087.61 916.53C1080.25 920.21 1074.35 925.736 1069.94 932.365C1065.52 938.993 1062.94 947.462 1061.84 957.039H1162.35Z"
fill="white"
/>
<!-- Center Circle -->
<circle
class="logo-stroke logo-circle"
cx="687.5"
cy="210.5"
r="209"
stroke="url(#paint17_linear_548_65)"
stroke-width="3"
fill="transparent"
<!-- Text: H -->
<path
class="text-letter text-letter-6"
d="M1472.01 779.537V1093.3H1399.48V964.773H1274.29V1093.3H1201.75V779.537H1274.29V901.065H1399.48V779.537H1472.01Z"
fill="white"
/>
<circle
class="logo-fill logo-fill-10"
cx="687.5"
cy="210.5"
r="209"
fill="#1D1D1D"
<!-- Text: u -->
<path
class="text-letter text-letter-7"
d="M1523.57 863.854H1594.26V995.323C1594.26 1008.95 1597.21 1019.63 1602.73 1026.99C1608.62 1034.36 1616.35 1038.04 1626.66 1038.04C1633.29 1038.04 1639.18 1036.94 1645.07 1034.73C1650.96 1032.52 1656.85 1029.2 1662.38 1024.42C1667.9 1019.63 1673.06 1013 1677.47 1005.26V864.221H1748.17V1015.21C1748.17 1022.2 1749.27 1026.99 1751.48 1029.94C1753.69 1032.89 1757.74 1034.36 1763.27 1034.73V1093.65C1756.64 1095.12 1751.11 1096.23 1746.33 1096.59C1741.54 1096.96 1737.12 1096.96 1733.44 1096.59C1720.92 1096.59 1711.35 1094.38 1703.98 1089.6C1696.62 1084.81 1692.2 1077.45 1690.36 1067.87L1688.89 1053.88C1678.21 1068.98 1665.32 1080.02 1649.49 1087.39C1634.03 1094.38 1616.35 1098.07 1596.47 1098.07C1572.9 1098.07 1554.86 1090.7 1542.34 1075.97C1529.83 1061.24 1523.57 1039.88 1523.57 1011.53V863.854Z"
fill="white"
/>
<!-- Text: b -->
<path
class="text-letter text-letter-8"
d="M1942.58 1097.71C1924.91 1097.71 1909.44 1094.02 1896.56 1086.66C1883.3 1079.29 1872.99 1068.61 1864.89 1054.99V1093.29H1803.4V770.689H1874.1V902.526C1881.83 889.269 1892.14 878.957 1904.66 871.594C1917.54 864.228 1932.64 860.544 1950.31 860.544C1964.67 860.544 1977.93 863.493 1990.45 869.383C2002.97 875.277 2013.28 883.746 2022.11 894.792C2030.95 905.842 2037.95 918.732 2043.1 933.094C2048.26 947.457 2050.47 963.292 2050.47 979.862C2050.47 996.435 2047.89 1011.53 2042.36 1026.27C2036.84 1040.99 2029.48 1053.52 2019.54 1064.2C2009.96 1074.88 1998.55 1083.34 1985.29 1089.24C1972.04 1095.13 1957.68 1098.08 1942.21 1098.08L1942.58 1097.71ZM1922.7 1038.05C1930.8 1038.05 1938.53 1036.58 1945.16 1033.63C1951.79 1030.68 1957.68 1026.63 1962.46 1021.48C1967.62 1016.32 1971.3 1010.06 1974.25 1003.06C1977.19 996.067 1978.3 988.334 1978.3 980.6C1978.3 969.55 1976.09 959.609 1971.67 950.402C1967.25 941.196 1960.99 933.829 1953.26 928.306C1945.53 922.783 1936.32 919.837 1926.38 919.837C1919.02 919.837 1912.02 921.677 1905.03 925.361C1898.4 929.044 1892.14 933.829 1886.98 939.723C1881.83 945.614 1877.41 952.242 1874.1 959.609V1007.48C1876.31 1011.9 1879.25 1016.32 1882.93 1020.37C1886.61 1024.42 1890.3 1027.37 1894.72 1030.32C1899.13 1032.89 1903.55 1035.1 1908.34 1036.58C1913.13 1038.05 1917.91 1038.78 1922.7 1038.78V1038.05Z"
fill="white"
/>
<defs>
<linearGradient id="paint0_linear_548_65" x1="124.804" y1="-297.569" x2="1351.77" y2="-186.399" gradientUnits="userSpaceOnUse">
<linearGradient id="splash_paint0" x1="245.48" y1="59.5055" x2="2566.89" y2="563.412" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint1_linear_548_65" x1="0" y1="1" x2="323" y2="419" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint2_linear_548_65" x1="1175.8" y1="-298.569" x2="2402.77" y2="-187.399" gradientUnits="userSpaceOnUse">
<linearGradient id="splash_paint1" x1="245.48" y1="59.5063" x2="2566.89" y2="563.412" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint3_linear_548_65" x1="1051" y1="0" x2="1374" y2="418" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint4_linear_548_65" x1="124.804" y1="684.431" x2="1351.77" y2="795.601" gradientUnits="userSpaceOnUse">
<linearGradient id="splash_paint2" x1="245.48" y1="59.5046" x2="2566.89" y2="563.411" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint5_linear_548_65" x1="323" y1="1067.2" x2="6.09023e-06" y2="1316.8" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint6_linear_548_65" x1="687" y1="1401" x2="687" y2="983" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
<linearGradient id="paint7_linear_548_65" x1="1175.8" y1="683.431" x2="2402.77" y2="794.601" gradientUnits="userSpaceOnUse">
<linearGradient id="splash_paint3" x1="1793.47" y1="-187.406" x2="3219.26" y2="26.8634" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint8_linear_548_65" x1="1374" y1="1066.2" x2="1051" y2="1315.8" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint9_linear_548_65" x1="-202.64" y1="492.439" x2="1176.93" y2="666.265" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint10_linear_548_65" x1="303.707" y1="557" x2="562.607" y2="896.973" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint11_linear_548_65" x1="162.651" y1="492.439" x2="1542.49" y2="666.336" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint12_linear_548_65" x1="669.104" y1="557" x2="927.989" y2="897.025" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint13_linear_548_65" x1="512.679" y1="492.439" x2="1933.21" y2="676.92" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint14_linear_548_65" x1="1034.57" y1="557" x2="1291.29" y2="904.456" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint15_linear_548_65" x1="-411.498" y1="492.439" x2="715.627" y2="607.854" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint16_linear_548_65" x1="0" y1="557" x2="268" y2="843" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint17_linear_548_65" x1="687.5" y1="0" x2="687.5" y2="421" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
</defs>
</svg>
</div>
@@ -272,69 +183,50 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: center;
padding: 0 1rem;
}
.splash-logo {
width: 200px;
height: 200px;
width: 280px;
height: auto;
}
@media (min-width: 768px) {
.splash-logo {
width: 400px;
height: 400px;
width: 600px;
}
}
/* Stroke Drawing Animation - Staggered timing */
@media (min-width: 1280px) {
.splash-logo {
width: 800px;
}
}
/* ========================================
Decorative shapes - Stroke drawing animation
======================================== */
.logo-stroke {
stroke-dasharray: 2000;
stroke-dashoffset: 2000;
stroke-dasharray: 3000;
stroke-dashoffset: 3000;
fill: transparent;
}
/* Top blocks draw first */
.logo-block-1 {
animation: drawStroke 1.5s ease-out 0s forwards, undrawStroke 1.5s ease-in 3.4s forwards;
/* Staggered draw-in from left to right */
.deco-stroke-1 {
animation: drawStroke 1.2s ease-out 0s forwards, undrawStroke 1.2s ease-in 3.4s forwards;
}
.logo-block-2 {
animation: drawStroke 1.5s ease-out 0.2s forwards, undrawStroke 1.5s ease-in 3.2s forwards;
.deco-stroke-2 {
animation: drawStroke 1.2s ease-out 0.2s forwards, undrawStroke 1.2s ease-in 3.2s forwards;
}
/* Center circle draws after top blocks */
.logo-circle {
animation: drawStroke 2s ease-out 0.4s forwards, undrawStroke 2s ease-in 2.5s forwards;
.deco-stroke-3 {
animation: drawStroke 1.2s ease-out 0.4s forwards, undrawStroke 1.2s ease-in 3.0s forwards;
}
/* Diagonals draw in sequence */
.logo-diagonal-1 {
animation: drawStroke 1.5s ease-out 0.6s forwards, undrawStroke 1.5s ease-in 2.8s forwards;
}
.logo-diagonal-2 {
animation: drawStroke 1.5s ease-out 0.8s forwards, undrawStroke 1.5s ease-in 2.6s forwards;
}
.logo-diagonal-3 {
animation: drawStroke 1.5s ease-out 1.0s forwards, undrawStroke 1.5s ease-in 2.4s forwards;
}
.logo-diagonal-4 {
animation: drawStroke 1.5s ease-out 1.2s forwards, undrawStroke 1.5s ease-in 2.2s forwards;
}
/* Bottom blocks draw last */
.logo-block-3 {
animation: drawStroke 1.5s ease-out 1.4s forwards, undrawStroke 1.5s ease-in 2.0s forwards;
}
.logo-block-4 {
animation: drawStroke 1.5s ease-out 1.6s forwards, undrawStroke 1.5s ease-in 1.8s forwards;
}
.logo-block-5 {
animation: drawStroke 1.5s ease-out 1.8s forwards, undrawStroke 1.5s ease-in 1.6s forwards;
.deco-stroke-4 {
animation: drawStroke 1.2s ease-out 0.6s forwards, undrawStroke 1.2s ease-in 2.8s forwards;
}
@keyframes drawStroke {
@@ -348,27 +240,32 @@ onMounted(() => {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 2000;
stroke-dashoffset: 3000;
}
}
/* Fill Appearance Animation - starts after strokes mostly drawn */
/* ========================================
Decorative shapes - Fill animation
======================================== */
.logo-fill {
opacity: 0;
animation: fillAppear 0.8s ease-out 1.2s forwards, fillDisappear 0.8s ease-in 3s forwards;
}
/* Stagger fill animations slightly for draw */
.logo-fill-1 { animation-delay: 1.2s, 3.9s; }
.logo-fill-2 { animation-delay: 1.3s, 3.8s; }
.logo-fill-3 { animation-delay: 1.4s, 3.7s; }
.logo-fill-4 { animation-delay: 1.5s, 3.6s; }
.logo-fill-5 { animation-delay: 1.6s, 3.5s; }
.logo-fill-6 { animation-delay: 1.7s, 3.4s; }
.logo-fill-7 { animation-delay: 1.8s, 3.3s; }
.logo-fill-8 { animation-delay: 1.9s, 3.2s; }
.logo-fill-9 { animation-delay: 2.0s, 3.1s; }
.logo-fill-10 { animation-delay: 2.1s, 3.0s; }
.deco-fill-1 {
animation: fillAppear 0.6s ease-out 0.8s forwards, fillDisappear 0.6s ease-in 3.6s forwards;
}
.deco-fill-2 {
animation: fillAppear 0.6s ease-out 1.0s forwards, fillDisappear 0.6s ease-in 3.4s forwards;
}
.deco-fill-3 {
animation: fillAppear 0.6s ease-out 1.2s forwards, fillDisappear 0.6s ease-in 3.2s forwards;
}
.deco-fill-4 {
animation: fillAppear 0.6s ease-out 1.4s forwards, fillDisappear 0.6s ease-in 3.0s forwards;
}
@keyframes fillAppear {
to {
@@ -385,7 +282,71 @@ onMounted(() => {
}
}
/* Splash Fade Out Transition */
/* ========================================
Text letters - Fade in one by one, then out
======================================== */
.text-letter {
opacity: 0;
}
/* Letters appear sequentially after the decorative shapes start filling */
.text-letter-1 {
animation: letterIn 0.4s ease-out 1.0s forwards, letterOut 0.4s ease-in 3.8s forwards;
}
.text-letter-2 {
animation: letterIn 0.4s ease-out 1.1s forwards, letterOut 0.4s ease-in 3.7s forwards;
}
.text-letter-3 {
animation: letterIn 0.4s ease-out 1.2s forwards, letterOut 0.4s ease-in 3.6s forwards;
}
.text-letter-4 {
animation: letterIn 0.4s ease-out 1.3s forwards, letterOut 0.4s ease-in 3.5s forwards;
}
.text-letter-5 {
animation: letterIn 0.4s ease-out 1.4s forwards, letterOut 0.4s ease-in 3.4s forwards;
}
.text-letter-6 {
animation: letterIn 0.4s ease-out 1.5s forwards, letterOut 0.4s ease-in 3.3s forwards;
}
.text-letter-7 {
animation: letterIn 0.4s ease-out 1.6s forwards, letterOut 0.4s ease-in 3.2s forwards;
}
.text-letter-8 {
animation: letterIn 0.4s ease-out 1.7s forwards, letterOut 0.4s ease-in 3.1s forwards;
}
@keyframes letterIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes letterOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}
/* ========================================
Splash screen fade-out transition
======================================== */
.splash-fade-leave-active {
transition: opacity 1.5s ease;
}

View File

@@ -0,0 +1,396 @@
<template>
<Transition name="splash-fade">
<div v-if="showSplash" class="splash-screen">
<div class="splash-content">
<svg
width="400"
height="400"
viewBox="0 0 1374 1401"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="splash-logo"
>
<!-- Top Left Block -->
<path
class="logo-stroke logo-block-1"
d="M321.5 2.5V417.5H1.5V2.5H321.5Z"
stroke="url(#paint1_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-1"
d="M321.5 2.5V417.5H1.5V2.5H321.5Z"
fill="url(#paint0_linear_548_65)"
/>
<!-- Top Right Block -->
<path
class="logo-stroke logo-block-2"
d="M1372.5 1.5V416.5H1052.5V1.5H1372.5Z"
stroke="url(#paint3_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-2"
d="M1372.5 1.5V416.5H1052.5V1.5H1372.5Z"
fill="url(#paint2_linear_548_65)"
/>
<!-- Bottom Left Block -->
<path
class="logo-stroke logo-block-3"
d="M321.5 984.5V1399.5H1.5V984.5H321.5Z"
stroke="url(#paint5_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-3"
d="M321.5 984.5V1399.5H1.5V984.5H321.5Z"
fill="url(#paint4_linear_548_65)"
/>
<!-- Center Bottom Block (Dark) -->
<path
class="logo-stroke logo-block-4"
d="M909.5 984.5V1399.5H464.5V984.5H909.5Z"
stroke="url(#paint6_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-4"
d="M909.5 984.5V1399.5H464.5V984.5H909.5Z"
fill="#1D1D1D"
/>
<!-- Bottom Right Block -->
<path
class="logo-stroke logo-block-5"
d="M1372.5 983.5V1398.5H1052.5V983.5H1372.5Z"
stroke="url(#paint8_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-5"
d="M1372.5 983.5V1398.5H1052.5V983.5H1372.5Z"
fill="url(#paint7_linear_548_65)"
/>
<!-- Center Diagonals -->
<path
class="logo-stroke logo-diagonal logo-diagonal-1"
d="M467.042 558.5L630.433 841.5H469.695L306.305 558.5H467.042Z"
stroke="url(#paint10_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-6"
d="M467.042 558.5L630.433 841.5H469.695L306.305 558.5H467.042Z"
fill="url(#paint9_linear_548_65)"
/>
<path
class="logo-stroke logo-diagonal logo-diagonal-2"
d="M832.507 558.5L995.897 841.5H835.092L671.701 558.5H832.507Z"
stroke="url(#paint12_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-7"
d="M832.507 558.5L995.897 841.5H835.092L671.701 558.5H832.507Z"
fill="url(#paint11_linear_548_65)"
/>
<path
class="logo-stroke logo-diagonal logo-diagonal-3"
d="M1372.5 558.5V841.5H1200.56L1037.17 558.5H1372.5Z"
stroke="url(#paint14_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-8"
d="M1372.5 558.5V841.5H1200.56L1037.17 558.5H1372.5Z"
fill="url(#paint13_linear_548_65)"
/>
<path
class="logo-stroke logo-diagonal logo-diagonal-4"
d="M101.646 558.5L265.036 841.5H1.5V558.5H101.646Z"
stroke="url(#paint16_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<path
class="logo-fill logo-fill-9"
d="M101.646 558.5L265.036 841.5H1.5V558.5H101.646Z"
fill="url(#paint15_linear_548_65)"
/>
<!-- Center Circle -->
<circle
class="logo-stroke logo-circle"
cx="687.5"
cy="210.5"
r="209"
stroke="url(#paint17_linear_548_65)"
stroke-width="3"
fill="transparent"
/>
<circle
class="logo-fill logo-fill-10"
cx="687.5"
cy="210.5"
r="209"
fill="#1D1D1D"
/>
<defs>
<linearGradient id="paint0_linear_548_65" x1="124.804" y1="-297.569" x2="1351.77" y2="-186.399" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint1_linear_548_65" x1="0" y1="1" x2="323" y2="419" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint2_linear_548_65" x1="1175.8" y1="-298.569" x2="2402.77" y2="-187.399" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint3_linear_548_65" x1="1051" y1="0" x2="1374" y2="418" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint4_linear_548_65" x1="124.804" y1="684.431" x2="1351.77" y2="795.601" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint5_linear_548_65" x1="323" y1="1067.2" x2="6.09023e-06" y2="1316.8" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint6_linear_548_65" x1="687" y1="1401" x2="687" y2="983" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
<linearGradient id="paint7_linear_548_65" x1="1175.8" y1="683.431" x2="2402.77" y2="794.601" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint8_linear_548_65" x1="1374" y1="1066.2" x2="1051" y2="1315.8" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F52532"/>
</linearGradient>
<linearGradient id="paint9_linear_548_65" x1="-202.64" y1="492.439" x2="1176.93" y2="666.265" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint10_linear_548_65" x1="303.707" y1="557" x2="562.607" y2="896.973" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint11_linear_548_65" x1="162.651" y1="492.439" x2="1542.49" y2="666.336" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint12_linear_548_65" x1="669.104" y1="557" x2="927.989" y2="897.025" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint13_linear_548_65" x1="512.679" y1="492.439" x2="1933.21" y2="676.92" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint14_linear_548_65" x1="1034.57" y1="557" x2="1291.29" y2="904.456" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint15_linear_548_65" x1="-411.498" y1="492.439" x2="715.627" y2="607.854" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0003D"/>
<stop offset="0.369792" stop-color="#FA4727"/>
<stop offset="0.776042" stop-color="#6B90F4"/>
</linearGradient>
<linearGradient id="paint16_linear_548_65" x1="0" y1="557" x2="268" y2="843" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA"/>
<stop offset="1" stop-color="#A5729F"/>
</linearGradient>
<linearGradient id="paint17_linear_548_65" x1="687.5" y1="0" x2="687.5" y2="421" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
</defs>
</svg>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const SPLASH_KEY = 'indeedhub_splash_shown'
const alreadyShown = sessionStorage.getItem(SPLASH_KEY) === 'true'
const showSplash = ref(!alreadyShown)
onMounted(() => {
if (showSplash.value) {
sessionStorage.setItem(SPLASH_KEY, 'true')
// Hide splash after animation completes (5s total animation)
setTimeout(() => {
showSplash.value = false
}, 5000)
}
})
</script>
<style scoped>
.splash-screen {
position: fixed;
inset: 0;
z-index: 9999;
background: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
}
.splash-content {
display: flex;
align-items: center;
justify-content: center;
}
.splash-logo {
width: 200px;
height: 200px;
}
@media (min-width: 768px) {
.splash-logo {
width: 400px;
height: 400px;
}
}
/* Stroke Drawing Animation - Staggered timing */
.logo-stroke {
stroke-dasharray: 2000;
stroke-dashoffset: 2000;
fill: transparent;
}
/* Top blocks draw first */
.logo-block-1 {
animation: drawStroke 1.5s ease-out 0s forwards, undrawStroke 1.5s ease-in 3.4s forwards;
}
.logo-block-2 {
animation: drawStroke 1.5s ease-out 0.2s forwards, undrawStroke 1.5s ease-in 3.2s forwards;
}
/* Center circle draws after top blocks */
.logo-circle {
animation: drawStroke 2s ease-out 0.4s forwards, undrawStroke 2s ease-in 2.5s forwards;
}
/* Diagonals draw in sequence */
.logo-diagonal-1 {
animation: drawStroke 1.5s ease-out 0.6s forwards, undrawStroke 1.5s ease-in 2.8s forwards;
}
.logo-diagonal-2 {
animation: drawStroke 1.5s ease-out 0.8s forwards, undrawStroke 1.5s ease-in 2.6s forwards;
}
.logo-diagonal-3 {
animation: drawStroke 1.5s ease-out 1.0s forwards, undrawStroke 1.5s ease-in 2.4s forwards;
}
.logo-diagonal-4 {
animation: drawStroke 1.5s ease-out 1.2s forwards, undrawStroke 1.5s ease-in 2.2s forwards;
}
/* Bottom blocks draw last */
.logo-block-3 {
animation: drawStroke 1.5s ease-out 1.4s forwards, undrawStroke 1.5s ease-in 2.0s forwards;
}
.logo-block-4 {
animation: drawStroke 1.5s ease-out 1.6s forwards, undrawStroke 1.5s ease-in 1.8s forwards;
}
.logo-block-5 {
animation: drawStroke 1.5s ease-out 1.8s forwards, undrawStroke 1.5s ease-in 1.6s forwards;
}
@keyframes drawStroke {
to {
stroke-dashoffset: 0;
}
}
@keyframes undrawStroke {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 2000;
}
}
/* Fill Appearance Animation - starts after strokes mostly drawn */
.logo-fill {
opacity: 0;
animation: fillAppear 0.8s ease-out 1.2s forwards, fillDisappear 0.8s ease-in 3s forwards;
}
/* Stagger fill animations slightly for draw */
.logo-fill-1 { animation-delay: 1.2s, 3.9s; }
.logo-fill-2 { animation-delay: 1.3s, 3.8s; }
.logo-fill-3 { animation-delay: 1.4s, 3.7s; }
.logo-fill-4 { animation-delay: 1.5s, 3.6s; }
.logo-fill-5 { animation-delay: 1.6s, 3.5s; }
.logo-fill-6 { animation-delay: 1.7s, 3.4s; }
.logo-fill-7 { animation-delay: 1.8s, 3.3s; }
.logo-fill-8 { animation-delay: 1.9s, 3.2s; }
.logo-fill-9 { animation-delay: 2.0s, 3.1s; }
.logo-fill-10 { animation-delay: 2.1s, 3.0s; }
@keyframes fillAppear {
to {
opacity: 1;
}
}
@keyframes fillDisappear {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Splash Fade Out Transition */
.splash-fade-leave-active {
transition: opacity 1.5s ease;
}
.splash-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,151 @@
import { ref, computed, onUnmounted } from 'vue'
import { Accounts } from 'applesauce-accounts'
import { accountManager } from '../lib/accounts'
import { TEST_PERSONAS, TASTEMAKER_PERSONAS } from '../data/testPersonas'
import type { Subscription } from 'rxjs'
type Persona = { name: string; nsec: string; pubkey: string }
/**
* Vue composable for Nostr account management.
* Wraps applesauce AccountManager observables as Vue reactive refs.
* Provides login methods for Extension, test personas, and private keys.
*/
export function useAccounts() {
const activeAccount = ref<any>(null)
const allAccounts = ref<any[]>([])
const isLoggingIn = ref(false)
const loginError = ref<string | null>(null)
const subscriptions: Subscription[] = []
// Subscribe to active account changes
const activeSub = accountManager.active$.subscribe((account) => {
activeAccount.value = account ?? null
})
subscriptions.push(activeSub)
// Subscribe to all accounts changes
const accountsSub = accountManager.accounts$.subscribe((accounts) => {
allAccounts.value = [...accounts]
})
subscriptions.push(accountsSub)
const isLoggedIn = computed(() => activeAccount.value !== null)
const activePubkey = computed(() => activeAccount.value?.pubkey ?? null)
const activeName = computed(() => {
if (!activeAccount.value) return null
// Check test personas for name
const allPersonas: Persona[] = [
...(TEST_PERSONAS as unknown as Persona[]),
...(TASTEMAKER_PERSONAS as unknown as Persona[]),
]
const match = allPersonas.find(
(p) => p.pubkey === activeAccount.value?.pubkey,
)
return match?.name ?? activeAccount.value?.pubkey?.slice(0, 8) ?? 'Unknown'
})
/**
* Login with a browser extension (NIP-07)
*/
async function loginWithExtension() {
isLoggingIn.value = true
loginError.value = null
try {
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Extension login failed'
console.error('Extension login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Login with a test persona (for development)
*/
async function loginWithPersona(persona: Persona) {
isLoggingIn.value = true
loginError.value = null
try {
const account = Accounts.PrivateKeyAccount.fromKey(persona.nsec)
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Persona login failed'
console.error('Persona login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Login with a private key (nsec)
*/
async function loginWithPrivateKey(nsec: string) {
isLoggingIn.value = true
loginError.value = null
try {
const account = Accounts.PrivateKeyAccount.fromKey(nsec)
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Private key login failed'
console.error('Private key login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Logout current account
*/
function logout() {
const current = accountManager.active
if (current) {
accountManager.removeAccount(current)
}
accountManager.setActive(null as any)
}
/**
* Get all available test personas
*/
const testPersonas = computed(() => [
...(TEST_PERSONAS as unknown as Persona[]),
])
const tastemakerPersonas = computed(() => [
...(TASTEMAKER_PERSONAS as unknown as Persona[]),
])
// Cleanup on unmount
onUnmounted(() => {
subscriptions.forEach((sub) => sub.unsubscribe())
})
return {
// State
activeAccount,
allAccounts,
isLoggedIn,
isLoggingIn,
loginError,
activePubkey,
activeName,
// Login methods
loginWithExtension,
loginWithPersona,
loginWithPrivateKey,
logout,
// Personas for dev UI
testPersonas,
tastemakerPersonas,
}
}

View File

@@ -0,0 +1,292 @@
import { ref, computed } from 'vue'
import { COMMENT_KIND } from 'applesauce-common/helpers'
import { mapEventsToStore } from 'applesauce-core'
import { onlyEvents } from 'applesauce-relay'
import type { NostrEvent } from 'applesauce-core/helpers/event'
import type { Subscription } from 'rxjs'
import { eventStore, pool, APP_RELAYS } from '../lib/nostr'
import { TASTEMAKER_PUBKEYS } from '../data/testPersonas'
import { getExternalContentId } from './useNostr'
import type { Content } from '../types/content'
// --- Algorithm Definitions ---
export const ALGORITHMS = [
{ id: 'popularity', label: 'Popularity' },
{ id: 'trending', label: 'Trending' },
{ id: 'most-zapped', label: 'Most Zapped' },
{ id: 'most-reviews', label: 'Most Reviews' },
{ id: 'tastemaker', label: 'Tastemaker' },
] as const
export type AlgorithmId = (typeof ALGORITHMS)[number]['id'] | null
export interface ContentStats {
plusCount: number
minusCount: number
commentCount: number
reviewCount: number
recentEvents: NostrEvent[]
}
const SEVEN_DAYS = 7 * 24 * 60 * 60
// --- Shared Module State (singleton) ---
const activeAlgorithm = ref<AlgorithmId>(null)
const contentStatsMap = ref<Map<string, ContentStats>>(new Map())
const isDiscoveryLoading = ref(false)
let subscriptionRefs: Subscription[] = []
let initialized = false
function getTagValue(event: NostrEvent, tagName: string): string | undefined {
return event.tags.find((t) => t[0] === tagName)?.[1]
}
/**
* Rebuild the stats map from current EventStore data
*/
function rebuildStats() {
const map = new Map<string, ContentStats>()
const getOrCreate = (id: string): ContentStats => {
let stats = map.get(id)
if (!stats) {
stats = {
plusCount: 0,
minusCount: 0,
commentCount: 0,
reviewCount: 0,
recentEvents: [],
}
map.set(id, stats)
}
return stats
}
// Process reactions from the EventStore
const reactions = eventStore.getByFilters([{ kinds: [17], '#k': ['web'] }])
if (reactions) {
for (const event of reactions) {
const externalId = getTagValue(event, 'i')
if (!externalId) continue
const stats = getOrCreate(externalId)
if (event.content === '+') stats.plusCount++
else if (event.content === '-') stats.minusCount++
stats.recentEvents.push(event)
}
}
// Process comments from the EventStore
const comments = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#K': ['web'] },
])
if (comments) {
for (const event of comments) {
const externalId = getTagValue(event, 'I')
if (!externalId) continue
const stats = getOrCreate(externalId)
stats.commentCount++
// Top-level comments also have #i (direct ref to external content)
if (getTagValue(event, 'i')) stats.reviewCount++
stats.recentEvents.push(event)
}
}
contentStatsMap.value = map
}
/**
* Start relay subscriptions for global discovery data.
* Only runs once; subsequent calls are no-ops.
*/
function initSubscriptions() {
if (initialized) return
initialized = true
isDiscoveryLoading.value = true
const currentRelays = APP_RELAYS
// Subscribe to all web reactions (kind 17)
const reactionSub = pool
.subscription(currentRelays, [{ kinds: [17], '#k': ['web'] }])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery reaction subscription error:', err),
})
subscriptionRefs.push(reactionSub)
// Subscribe to all web comments (kind 1111)
const commentSub = pool
.subscription(currentRelays, [
{ kinds: [COMMENT_KIND], '#K': ['web'] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery comment subscription error:', err),
})
subscriptionRefs.push(commentSub)
// Initial build from any already-cached events
rebuildStats()
// Mark loading done after initial data window
setTimeout(() => {
isDiscoveryLoading.value = false
}, 2000)
}
// --- Public Composable ---
const EMPTY_STATS: ContentStats = {
plusCount: 0,
minusCount: 0,
commentCount: 0,
reviewCount: 0,
recentEvents: [],
}
/**
* Composable providing discovery algorithm state and content sorting.
*
* Shared state: `activeAlgorithm` and `contentStatsMap` are singletons
* so that the header, mobile nav, and browse view all see the same state.
*/
export function useContentDiscovery() {
// Ensure relay subscriptions are running
initSubscriptions()
/**
* Sort an array of Content items by the active algorithm.
* Returns content as-is when no filter is active (null).
*/
const sortContent = computed(() => {
return (contents: Content[], overrideAlgo?: ActiveAlgorithmId): Content[] => {
if (!contents || contents.length === 0) return contents
const effectiveAlgo: ActiveAlgorithmId | null = overrideAlgo ?? activeAlgorithm.value
if (!effectiveAlgo) return contents
const statsMap = contentStatsMap.value
const origin = window.location.origin
// Build entries array: [Content, stats] for each content item
const withStats: [Content, ContentStats][] = contents.map((c) => {
const externalId = `${origin}/content/${c.id}`
return [c, statsMap.get(externalId) || EMPTY_STATS]
})
// Sort entries
const sortedEntries = sortContentEntries(withStats, effectiveAlgo)
return sortedEntries.map(([content]) => content)
}
})
/**
* The label for the currently active algorithm, or null if none.
*/
const activeAlgorithmLabel = computed(() => {
if (!activeAlgorithm.value) return null
return ALGORITHMS.find((a) => a.id === activeAlgorithm.value)?.label ?? null
})
/**
* Whether a filter is currently active
*/
const isFilterActive = computed(() => activeAlgorithm.value !== null)
/**
* Set algorithm. Clicking the same algorithm again toggles it off.
*/
function setAlgorithm(algo: AlgorithmId) {
if (activeAlgorithm.value === algo) {
activeAlgorithm.value = null
} else {
activeAlgorithm.value = algo
}
}
/**
* Get stats for a specific content item
*/
function getStats(contentId: string): ContentStats {
const externalId = getExternalContentId(contentId)
return contentStatsMap.value.get(externalId) || EMPTY_STATS
}
return {
// State
activeAlgorithm,
activeAlgorithmLabel,
isFilterActive,
algorithms: ALGORITHMS,
contentStatsMap,
isDiscoveryLoading,
// Methods
sortContent,
setAlgorithm,
getStats,
}
}
// --- Internal sorting helper for Content tuples ---
type ActiveAlgorithmId = Exclude<AlgorithmId, null>
function sortContentEntries(
entries: [Content, ContentStats][],
algo: ActiveAlgorithmId,
): [Content, ContentStats][] {
const now = Math.floor(Date.now() / 1000)
switch (algo) {
case 'popularity':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
(b[1].plusCount - b[1].minusCount + b[1].commentCount) -
(a[1].plusCount - a[1].minusCount + a[1].commentCount),
)
case 'trending': {
const trendScore = (stats: ContentStats) => {
let score = 0
for (const e of stats.recentEvents) {
score += e.created_at > now - SEVEN_DAYS ? 2 : 1
}
return score
}
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
trendScore(b[1]) - trendScore(a[1]),
)
}
case 'most-zapped':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
b[1].plusCount - a[1].plusCount,
)
case 'most-reviews':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
b[1].reviewCount - a[1].reviewCount,
)
case 'tastemaker': {
if (TASTEMAKER_PUBKEYS.length === 0) {
return sortContentEntries(entries, 'popularity')
}
const filtered = entries.filter(([, stats]) =>
stats.recentEvents.some((e) => TASTEMAKER_PUBKEYS.includes(e.pubkey)),
)
return sortContentEntries(
filtered.length > 0 ? filtered : entries,
'popularity',
)
}
}
}

View File

@@ -1,316 +1,440 @@
import { ref, computed, onUnmounted } from 'vue'
import { nostrClient } from '../lib/nostr'
import { getNostrContentIdentifier } from '../utils/mappers'
import { getMockComments, getMockReactions, getMockProfile, mockProfiles } from '../data/mockSocialData'
import type { Event as NostrEvent } from 'nostr-tools'
import { COMMENT_KIND } from 'applesauce-common/helpers'
import { mapEventsToStore } from 'applesauce-core'
import { onlyEvents } from 'applesauce-relay'
import type { NostrEvent } from 'applesauce-core/helpers/event'
import type { Subscription } from 'rxjs'
import { eventStore, pool, appRelays, factory, APP_RELAYS } from '../lib/nostr'
import { accountManager } from '../lib/accounts'
import { useObservable } from './useObservable'
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
/**
* Build the external content identifier used in Nostr tags.
* Matches indeehub convention: {origin}/content/{contentId}
*/
export function getExternalContentId(contentId: string): string {
return `${window.location.origin}/content/${contentId}`
}
// --- Comment Tree Types ---
export interface CommentNode {
event: NostrEvent
replies: CommentNode[]
}
/**
* Build a threaded comment tree from flat event arrays.
* Top-level comments have #i = externalId.
* Replies reference parent via #e tag.
*/
function buildCommentTree(
topLevel: NostrEvent[],
allInThread: NostrEvent[],
): CommentNode[] {
// Group replies by parent event ID
const childrenMap = new Map<string, NostrEvent[]>()
const topLevelIds = new Set(topLevel.map((e) => e.id))
for (const event of allInThread) {
// Skip if it's a top-level comment
if (topLevelIds.has(event.id)) continue
const parentTag = event.tags.find((t) => t[0] === 'e')
if (parentTag) {
const parentId = parentTag[1]
if (!childrenMap.has(parentId)) childrenMap.set(parentId, [])
childrenMap.get(parentId)!.push(event)
}
}
function buildNode(event: NostrEvent): CommentNode {
const children = childrenMap.get(event.id) || []
return {
event,
replies: [...children]
.sort((a, b) => a.created_at - b.created_at)
.map(buildNode),
}
}
return [...topLevel]
.sort((a, b) => b.created_at - a.created_at)
.map(buildNode)
}
/**
* Nostr Composable
* Reactive interface for Nostr features
* Uses mock data in development mode
* Real Nostr integration using applesauce stack.
* Subscribes to relays for comments (kind 1111) and reactions (kind 17).
* Supports threaded replies, per-comment reactions, and user vote state.
*/
export function useNostr(contentId?: string) {
const comments = ref<NostrEvent[]>([])
const relays = useObservable(appRelays, [...APP_RELAYS])
// Comment tree (threaded)
const commentTree = ref<CommentNode[]>([])
// Flat list of all comment events in thread
const allComments = ref<NostrEvent[]>([])
// Movie-level reactions
const reactions = ref<NostrEvent[]>([])
// Per-comment reactions: eventId -> NostrEvent[]
const commentReactions = ref<Map<string, NostrEvent[]>>(new Map())
// User profiles
const profiles = ref<Map<string, any>>(new Map())
// Loading state
const isLoading = ref(false)
const error = ref<string | null>(null)
// Current external ID being viewed
let currentExternalId = ''
let commentSub: any = null
let reactionSub: any = null
const subscriptions: Subscription[] = []
/**
* Fetch comments for content
* Subscribe to comments and reactions for a given content ID.
*/
async function fetchComments(id: string = contentId!) {
if (!id) return
function subscribeToContent(id: string) {
const externalId = getExternalContentId(id)
currentExternalId = externalId
const currentRelays = relays.value ?? APP_RELAYS
isLoading.value = true
error.value = null
try {
if (useMockData) {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 200))
const mockComments = getMockComments(id)
comments.value = mockComments as unknown as NostrEvent[]
// Subscribe to top-level comments (#i) and all thread comments (#I)
const commentSub = pool
.subscription(currentRelays, [
{ kinds: [COMMENT_KIND], '#i': [externalId] },
{ kinds: [COMMENT_KIND], '#I': [externalId] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => refreshComments(externalId),
error: (err) => {
console.error('Comment subscription error:', err)
error.value = 'Failed to load comments'
isLoading.value = false
},
})
subscriptions.push(commentSub)
// Populate profiles from mock data
mockComments.forEach((comment) => {
const profile = getMockProfile(comment.pubkey)
if (profile) {
profiles.value.set(comment.pubkey, {
name: profile.name,
picture: profile.picture,
about: profile.about,
})
}
})
return
}
// Subscribe to movie-level reactions (kind 17)
const reactionSub = pool
.subscription(currentRelays, [
{ kinds: [17], '#i': [externalId] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => refreshReactions(externalId),
error: (err) => console.error('Reaction subscription error:', err),
})
subscriptions.push(reactionSub)
const identifier = getNostrContentIdentifier(id)
const events = await nostrClient.getComments(identifier)
// Sort by timestamp (newest first)
comments.value = events.sort((a, b) => b.created_at - a.created_at)
// Read existing data from store immediately
refreshComments(externalId)
refreshReactions(externalId)
// Fetch profiles for comment authors
await fetchProfiles(events.map((e) => e.pubkey))
} catch (err: any) {
error.value = err.message || 'Failed to fetch comments'
console.error('Nostr comments error:', err)
} finally {
// Mark loading done after initial data window
setTimeout(() => {
isLoading.value = false
}
}, 1500)
}
/**
* Fetch reactions for content
* Read all comments from the EventStore and build the threaded tree.
*/
async function fetchReactions(id: string = contentId!) {
if (!id) return
function refreshComments(externalId: string) {
// Top-level comments: have #i = externalId
const topLevel = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#i': [externalId] },
])
// All comments in thread: have #I = externalId (includes replies)
const allInThread = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#I': [externalId] },
])
try {
if (useMockData) {
await new Promise((resolve) => setTimeout(resolve, 100))
reactions.value = getMockReactions(id) as unknown as NostrEvent[]
return
}
const topLevelEvents = topLevel ? [...topLevel] : []
const allEvents = allInThread ? [...allInThread] : []
const identifier = getNostrContentIdentifier(id)
const events = await nostrClient.getReactions(identifier)
reactions.value = events
} catch (err: any) {
console.error('Nostr reactions error:', err)
// Merge: allInThread should include top-level too, but add any missed
const allIds = new Set(allEvents.map((e) => e.id))
for (const e of topLevelEvents) {
if (!allIds.has(e.id)) allEvents.push(e)
}
allComments.value = allEvents
commentTree.value = buildCommentTree(topLevelEvents, allEvents)
// Fetch profiles for all comment authors
const pubkeys = [...new Set(allEvents.map((e) => e.pubkey))]
fetchProfiles(pubkeys)
// Load reactions for all comments
loadCommentReactions(allEvents)
}
/**
* Read movie-level reactions from the EventStore.
*/
function refreshReactions(externalId: string) {
const events = eventStore.getByFilters([
{ kinds: [17], '#i': [externalId] },
])
if (events) {
reactions.value = [...events]
}
}
/**
* Fetch user profiles
* Load reactions for comment events using reactionsLoader.
*/
function loadCommentReactions(commentEvents: NostrEvent[]) {
const currentRelays = relays.value ?? APP_RELAYS
for (const event of commentEvents) {
// Subscribe to reactions for this comment
const sub = pool
.subscription(currentRelays, [
{ kinds: [7], '#e': [event.id] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => refreshSingleCommentReactions(event.id),
})
subscriptions.push(sub)
// Also read existing reactions from store
refreshSingleCommentReactions(event.id)
}
}
/**
* Refresh reactions for a single comment event from the store.
*/
function refreshSingleCommentReactions(eventId: string) {
const events = eventStore.getByFilters([
{ kinds: [7], '#e': [eventId] },
])
if (events) {
const newMap = new Map(commentReactions.value)
newMap.set(eventId, [...events])
commentReactions.value = newMap
}
}
/**
* Fetch user profiles for comment authors.
*/
async function fetchProfiles(pubkeys: string[]) {
const uniquePubkeys = [...new Set(pubkeys)]
const currentRelays = relays.value ?? APP_RELAYS
await Promise.all(
uniquePubkeys.map(async (pubkey) => {
if (profiles.value.has(pubkey)) return
for (const pubkey of pubkeys) {
if (profiles.value.has(pubkey)) continue
if (useMockData) {
const profile = getMockProfile(pubkey)
if (profile) {
profiles.value.set(pubkey, {
name: profile.name,
picture: profile.picture,
about: profile.about,
})
}
return
}
try {
const profileSub = pool
.subscription([...currentRelays, 'wss://purplepag.es'], [
{ kinds: [0], authors: [pubkey], limit: 1 },
])
.pipe(onlyEvents())
.subscribe({
next: (event) => {
try {
const metadata = JSON.parse(event.content)
profiles.value = new Map(profiles.value).set(pubkey, metadata)
} catch {
// Invalid profile JSON
}
},
})
try {
const profileEvent = await nostrClient.getProfile(pubkey)
if (profileEvent) {
const metadata = JSON.parse(profileEvent.content)
profiles.value.set(pubkey, metadata)
}
} catch (err) {
console.error(`Failed to fetch profile for ${pubkey}:`, err)
}
})
)
}
/**
* Subscribe to real-time comments
*/
function subscribeToComments(id: string = contentId!) {
if (!id || commentSub) return
if (useMockData) {
// In mock mode, no real-time subscription needed
return
}
const identifier = getNostrContentIdentifier(id)
commentSub = nostrClient.subscribeToComments(
identifier,
(event) => {
comments.value = [event, ...comments.value]
fetchProfiles([event.pubkey])
setTimeout(() => profileSub.unsubscribe(), 5000)
subscriptions.push(profileSub)
} catch (err) {
console.error(`Failed to fetch profile for ${pubkey}:`, err)
}
)
}
/**
* Subscribe to real-time reactions
*/
function subscribeToReactions(id: string = contentId!) {
if (!id || reactionSub) return
if (useMockData) {
return
}
const identifier = getNostrContentIdentifier(id)
reactionSub = nostrClient.subscribeToReactions(
identifier,
(event) => {
reactions.value = [...reactions.value, event]
}
)
}
// --- Posting Methods ---
/**
* Post a comment
* Post a top-level comment on content.
* Uses kind 1111 with proper #i, #I, #k, #K tags.
*/
async function postComment(content: string, id: string = contentId!) {
if (!id) {
throw new Error('Content ID required')
}
if (!id) throw new Error('Content ID required')
if (useMockData) {
// In mock mode, add the comment locally
const mockProfile = mockProfiles[0]
const newComment = {
id: Math.random().toString(36).slice(2).padEnd(64, '0'),
pubkey: mockProfile.pubkey,
content,
created_at: Math.floor(Date.now() / 1000),
kind: 1 as const,
tags: [['i', `https://indeedhub.com/content/${id}`, 'text']],
sig: '0'.repeat(128),
}
comments.value = [newComment as unknown as NostrEvent, ...comments.value]
const account = accountManager.active
if (!account) throw new Error('Not logged in')
if (!profiles.value.has(mockProfile.pubkey)) {
profiles.value.set(mockProfile.pubkey, {
name: mockProfile.name,
picture: mockProfile.picture,
about: mockProfile.about,
})
}
return newComment
}
if (!window.nostr) {
throw new Error('Nostr extension not available')
}
const externalId = getExternalContentId(id)
try {
const pubkey = await window.nostr.getPublicKey()
const identifier = getNostrContentIdentifier(id)
const event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [
['i', identifier, 'text'],
],
const template = await factory.comment(
{ type: 'external', kind: 'web', identifier: externalId },
content,
pubkey,
}
const signedEvent = await window.nostr.signEvent(event)
await nostrClient.publishEvent(signedEvent)
return signedEvent
)
const signed = await factory.sign(template)
await pool.publish(APP_RELAYS, signed)
eventStore.add(signed)
refreshComments(externalId)
return signed
} catch (err: any) {
throw new Error(err.message || 'Failed to post comment')
}
}
/**
* Post a reaction (+1 or -1)
* Post a reply to an existing comment event.
* Uses factory.comment(parentEvent, content) which handles all tagging.
*/
async function postReaction(positive: boolean, id: string = contentId!) {
if (!id) {
throw new Error('Content ID required')
}
if (useMockData) {
const mockProfile = mockProfiles[0]
const newReaction = {
id: Math.random().toString(36).slice(2).padEnd(64, '0'),
pubkey: mockProfile.pubkey,
content: positive ? '+' : '-',
created_at: Math.floor(Date.now() / 1000),
kind: 17 as const,
tags: [['i', `https://indeedhub.com/content/${id}`, 'text']],
sig: '0'.repeat(128),
}
reactions.value = [...reactions.value, newReaction as unknown as NostrEvent]
return newReaction
}
if (!window.nostr) {
throw new Error('Nostr extension not available')
}
async function postReply(parentEvent: NostrEvent, content: string) {
const account = accountManager.active
if (!account) throw new Error('Not logged in')
try {
const pubkey = await window.nostr.getPublicKey()
const identifier = getNostrContentIdentifier(id)
const template = await factory.comment(parentEvent, content)
const signed = await factory.sign(template)
await pool.publish(APP_RELAYS, signed)
eventStore.add(signed)
const event = {
// Refresh thread
if (currentExternalId) {
refreshComments(currentExternalId)
}
return signed
} catch (err: any) {
throw new Error(err.message || 'Failed to post reply')
}
}
/**
* Post a reaction (+/-) on the movie/content itself.
* Uses kind 17 with #i and #k tags.
*/
async function postReaction(positive: boolean, id: string = contentId!) {
if (!id) throw new Error('Content ID required')
const account = accountManager.active
if (!account) throw new Error('Not logged in')
const externalId = getExternalContentId(id)
try {
const template = {
kind: 17,
created_at: Math.floor(Date.now() / 1000),
tags: [
['i', identifier, 'text'],
],
content: positive ? '+' : '-',
pubkey,
tags: [
['i', externalId],
['k', 'web'],
],
created_at: Math.floor(Date.now() / 1000),
}
const signedEvent = await window.nostr.signEvent(event)
await nostrClient.publishEvent(signedEvent)
return signedEvent
const signed = await factory.sign(template)
await pool.publish(APP_RELAYS, signed)
eventStore.add(signed)
refreshReactions(externalId)
return signed
} catch (err: any) {
throw new Error(err.message || 'Failed to post reaction')
}
}
/**
* Get reaction counts
* React to a comment event (+/-).
* Uses factory.reaction(event, emoji) which creates kind 7 events.
*/
async function reactToComment(event: NostrEvent, positive: boolean) {
const account = accountManager.active
if (!account) throw new Error('Not logged in')
const currentRelays = relays.value ?? APP_RELAYS
try {
const template = await factory.reaction(event, positive ? '+' : '-')
const signed = await factory.sign(template)
await pool.publish(currentRelays, signed)
eventStore.add(signed)
refreshSingleCommentReactions(event.id)
return signed
} catch (err: any) {
throw new Error(err.message || 'Failed to react to comment')
}
}
// --- Computed State ---
/**
* Movie-level reaction counts.
*/
const reactionCounts = computed(() => {
const positive = reactions.value.filter((r) => r.content === '+').length
const negative = reactions.value.filter((r) => r.content === '-').length
return { positive, negative, total: positive - negative }
})
/**
* Get user's reaction
* Current user's movie-level reaction, read from relay data.
*/
async function getUserReaction(id: string = contentId!) {
if (!id) return null
const userContentReaction = computed((): string | null => {
const account = accountManager.active
if (!account) return null
const userReaction = reactions.value.find((r) => r.pubkey === account.pubkey)
return userReaction?.content || null
})
if (useMockData) {
return null // Mock user has no existing reaction
}
/**
* Whether the current user has already voted on the movie.
*/
const hasVotedOnContent = computed(() => userContentReaction.value !== null)
if (!window.nostr) return null
try {
const pubkey = await window.nostr.getPublicKey()
const userReaction = reactions.value.find((r) => r.pubkey === pubkey)
return userReaction?.content || null
} catch {
return null
}
/**
* Get reaction counts for a specific comment event.
*/
function getCommentReactionCounts(eventId: string) {
const events = commentReactions.value.get(eventId) || []
const positive = events.filter((r) => r.content === '+').length
const negative = events.filter((r) => r.content === '-').length
return { positive, negative }
}
/**
* Cleanup subscriptions
* Get the current user's reaction on a specific comment.
*/
function getUserCommentReaction(eventId: string): string | null {
const account = accountManager.active
if (!account) return null
const events = commentReactions.value.get(eventId) || []
const userReaction = events.find((r) => r.pubkey === account.pubkey)
return userReaction?.content || null
}
/**
* Whether the user has already reacted to a specific comment.
*/
function hasVotedOnComment(eventId: string): boolean {
return getUserCommentReaction(eventId) !== null
}
/**
* Total comment count (all in thread including replies).
*/
const commentCount = computed(() => allComments.value.length)
/**
* Cleanup all subscriptions.
*/
function cleanup() {
if (commentSub) commentSub.close()
if (reactionSub) reactionSub.close()
subscriptions.forEach((sub) => sub.unsubscribe())
subscriptions.length = 0
currentExternalId = ''
}
// Auto-subscribe when contentId is provided
if (contentId) {
subscribeToContent(contentId)
}
// Auto-cleanup on unmount
@@ -320,31 +444,27 @@ export function useNostr(contentId?: string) {
return {
// State
comments,
commentTree,
allComments,
reactions,
commentReactions,
profiles,
isLoading,
error,
reactionCounts,
userContentReaction,
hasVotedOnContent,
commentCount,
// Methods
fetchComments,
fetchReactions,
subscribeToComments,
subscribeToReactions,
subscribeToContent,
postComment,
postReply,
postReaction,
getUserReaction,
reactToComment,
getCommentReactionCounts,
getUserCommentReaction,
hasVotedOnComment,
cleanup,
}
}
// Declare window.nostr for TypeScript
declare global {
interface Window {
nostr?: {
getPublicKey: () => Promise<string>
signEvent: (event: any) => Promise<any>
}
}
}

View File

@@ -0,0 +1,36 @@
import { ref, onUnmounted, type Ref } from 'vue'
import type { Observable, Subscription } from 'rxjs'
/**
* Vue composable that subscribes to an RxJS Observable and returns a reactive ref.
* This is the Vue equivalent of applesauce-react's `use$()` hook.
*
* @param observable$ - The RxJS Observable or BehaviorSubject to subscribe to
* @param initialValue - Optional initial value before the first emission
* @returns A Vue ref that updates whenever the observable emits
*/
export function useObservable<T>(
observable$: Observable<T>,
initialValue?: T,
): Ref<T | undefined> {
const value = ref<T | undefined>(initialValue) as Ref<T | undefined>
let subscription: Subscription | null = null
subscription = observable$.subscribe({
next: (v) => {
value.value = v
},
error: (err) => {
console.error('useObservable error:', err)
},
})
onUnmounted(() => {
if (subscription) {
subscription.unsubscribe()
subscription = null
}
})
return value
}

View File

@@ -9,7 +9,7 @@ export const indeeHubFilms: Content[] = [
title: 'God Bless Bitcoin',
description: 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin and its transformative impact on religious communities worldwide.',
thumbnail: '/images/films/posters/god-bless-bitcoin.webp',
backdrop: '/images/films/backdrops/god-bless-bitcoin.webp',
backdrop: '/images/films/backdrops/god-bless-bitcoin.jpg',
type: 'film',
duration: 90,
releaseYear: 2024,
@@ -41,7 +41,7 @@ export const indeeHubFilms: Content[] = [
title: 'Hard Money',
description: 'Understanding sound money principles and the importance of monetary sovereignty in the modern financial system.',
thumbnail: '/images/films/posters/2b0d7349-c010-47a0-b584-49e1bf86ab2f.png',
backdrop: '/images/films/backdrops/2b0d7349-c010-47a0-b584-49e1bf86ab2f.png',
backdrop: '/images/films/backdrops/2b0d7349-c010-47a0-b584-49e1bf86ab2f.jpg',
type: 'film',
categories: ['Documentary', 'Finance', 'Bitcoin'],
nostrEventId: ''
@@ -51,7 +51,7 @@ export const indeeHubFilms: Content[] = [
title: 'Bitcoiners',
description: 'Meet the passionate individuals building the Bitcoin ecosystem and changing the world of money.',
thumbnail: '/images/films/posters/665a4095-73b9-480d-a0a4-b2aafaf2bce4.png',
backdrop: '/images/films/backdrops/665a4095-73b9-480d-a0a4-b2aafaf2bce4.png',
backdrop: '/images/films/backdrops/665a4095-73b9-480d-a0a4-b2aafaf2bce4.jpg',
type: 'film',
categories: ['Documentary', 'Bitcoin'],
nostrEventId: ''
@@ -61,7 +61,7 @@ export const indeeHubFilms: Content[] = [
title: 'Lekker Feeling: A Bitcoin Ekasi Story',
description: 'A heartwarming documentary about Bitcoin adoption in South African townships and its impact on local communities.',
thumbnail: '/images/films/posters/3c113b66-3bb5-4cac-90eb-965ecedc4aa2.png',
backdrop: '/images/films/backdrops/3c113b66-3bb5-4cac-90eb-965ecedc4aa2.png',
backdrop: '/images/films/backdrops/3c113b66-3bb5-4cac-90eb-965ecedc4aa2.jpg',
type: 'film',
categories: ['Documentary', 'Bitcoin'],
nostrEventId: ''
@@ -81,7 +81,7 @@ export const indeeHubFilms: Content[] = [
title: 'The Housing Bubble',
description: 'An examination of the 2008 financial crisis, mortgage-backed securities, and the devastating impact on American families.',
thumbnail: '/images/films/posters/bbdb0178-0b96-4ab5-addf-ba1f029c1cb3.webp',
backdrop: '/images/films/backdrops/bbdb0178-0b96-4ab5-addf-ba1f029c1cb3.webp',
backdrop: '/images/films/backdrops/bbdb0178-0b96-4ab5-addf-ba1f029c1cb3.jpg',
type: 'film',
categories: ['Documentary', 'Finance'],
nostrEventId: ''
@@ -91,7 +91,7 @@ export const indeeHubFilms: Content[] = [
title: 'Menger. Notes on the margin',
description: 'Exploring Austrian economics, Carl Menger\'s revolutionary ideas on subjective value, and the foundations of sound economic thinking.',
thumbnail: '/images/films/posters/584f310b-2269-4b05-a09d-261a0a3c1f78.webp',
backdrop: '/images/films/backdrops/584f310b-2269-4b05-a09d-261a0a3c1f78.webp',
backdrop: '/images/films/backdrops/584f310b-2269-4b05-a09d-261a0a3c1f78.jpg',
type: 'film',
categories: ['Documentary', 'Economics'],
nostrEventId: ''
@@ -211,7 +211,7 @@ export const indeeHubFilms: Content[] = [
title: 'Time Traveling Thieves',
description: 'A mind-bending adventure through time with unexpected twists.',
thumbnail: '/images/films/posters/5bd753b7-9ff1-4966-a1c4-b3b93c62ed5d.webp',
backdrop: '/images/films/backdrops/5bd753b7-9ff1-4966-a1c4-b3b93c62ed5d.webp',
backdrop: '/images/films/backdrops/5bd753b7-9ff1-4966-a1c4-b3b93c62ed5d.jpg',
type: 'film',
categories: ['Sci-Fi', 'Adventure'],
nostrEventId: ''

View File

@@ -1,218 +0,0 @@
/**
* Mock Social Data for Development Mode
* Provides realistic comments, reactions, and profiles without Nostr relays
*/
export interface MockProfile {
name: string
picture: string
about: string
npub: string
pubkey: string
}
export interface MockComment {
id: string
pubkey: string
content: string
created_at: number
kind: 1
tags: string[][]
sig: string
}
export interface MockReaction {
id: string
pubkey: string
content: '+' | '-'
created_at: number
kind: 17
tags: string[][]
sig: string
}
// Mock Nostr profiles
export const mockProfiles: MockProfile[] = [
{
name: 'BitcoinFilmFan',
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=BitcoinFilmFan',
about: 'Independent film lover and Bitcoin enthusiast.',
npub: 'npub1mockuser1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60001',
},
{
name: 'CinephileMax',
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=CinephileMax',
about: 'Watching everything, one film at a time.',
npub: 'npub1mockuser2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60002',
},
{
name: 'DocuLover',
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=DocuLover',
about: 'Documentaries are the highest form of cinema.',
npub: 'npub1mockuser3xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60003',
},
{
name: 'SatoshiScreens',
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=SatoshiScreens',
about: 'Film meets freedom tech. V4V.',
npub: 'npub1mockuser4xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60004',
},
{
name: 'IndieFilmNerd',
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=IndieFilmNerd',
about: 'Supporting independent filmmakers everywhere.',
npub: 'npub1mockuser5xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60005',
},
]
// Comment templates per content ID
const commentTemplates: Record<string, string[]> = {
'god-bless-bitcoin': [
'This documentary completely changed how I think about Bitcoin and faith. Must watch.',
'Incredible storytelling. The parallels between monetary sovereignty and spiritual freedom are powerful.',
'Shared this with my entire church group. Everyone was blown away.',
'Finally a Bitcoin documentary that goes beyond the price charts. Beautiful work.',
],
'thethingswecarry': [
'Such a deeply emotional film. Brought me to tears.',
'The cinematography is stunning. Every frame tells a story.',
'This is what independent cinema should be. Raw and real.',
],
'duel': [
'Edge-of-your-seat tension from start to finish. Brilliant directing.',
'The performances are incredible. You can feel the weight of every decision.',
'Rewatched this three times already. Catches something new each time.',
],
}
// Generic comments for content without specific templates
const genericComments = [
'Really enjoyed this one. Great production quality.',
'IndeeHub keeps finding amazing content. This platform is the future.',
'Watching this made my evening. Highly recommend.',
'The filmmakers clearly put their heart into this. It shows.',
'More people need to see this. Sharing with everyone I know.',
'Just finished watching. Need a moment to process how good that was.',
'This is why I subscribe. Quality content that you can not find elsewhere.',
'Beautiful film. The score and visuals work perfectly together.',
]
/**
* Generate a mock event ID (hex string)
*/
function mockEventId(seed: number): string {
return seed.toString(16).padStart(64, '0')
}
/**
* Generate a mock signature (hex string)
*/
function mockSig(seed: number): string {
return seed.toString(16).padStart(128, 'f')
}
/**
* Get mock comments for a given content ID
*/
export function getMockComments(contentId: string): MockComment[] {
const templates = commentTemplates[contentId] || genericComments
const now = Math.floor(Date.now() / 1000)
// Pick 3-5 comments
const count = 3 + Math.floor(Math.abs(hashCode(contentId)) % 3)
const comments: MockComment[] = []
for (let i = 0; i < count && i < templates.length; i++) {
const profile = mockProfiles[i % mockProfiles.length]
const hoursAgo = (i + 1) * 3 + Math.floor(Math.abs(hashCode(contentId + i)) % 12)
comments.push({
id: mockEventId(hashCode(contentId + 'comment' + i)),
pubkey: profile.pubkey,
content: templates[i % templates.length],
created_at: now - hoursAgo * 3600,
kind: 1,
tags: [['i', `https://indeedhub.com/content/${contentId}`, 'text']],
sig: mockSig(hashCode(contentId + 'sig' + i)),
})
}
return comments.sort((a, b) => b.created_at - a.created_at)
}
/**
* Get mock reactions for a given content ID
*/
export function getMockReactions(contentId: string): MockReaction[] {
const reactions: MockReaction[] = []
const now = Math.floor(Date.now() / 1000)
// Generate between 5-15 reactions
const count = 5 + Math.floor(Math.abs(hashCode(contentId)) % 11)
for (let i = 0; i < count; i++) {
const profile = mockProfiles[i % mockProfiles.length]
// ~80% positive reactions
const isPositive = (hashCode(contentId + 'react' + i) % 10) < 8
reactions.push({
id: mockEventId(hashCode(contentId + 'reaction' + i)),
pubkey: profile.pubkey + i.toString(16).padStart(4, '0'),
content: isPositive ? '+' : '-',
created_at: now - i * 1800,
kind: 17,
tags: [['i', `https://indeedhub.com/content/${contentId}`, 'text']],
sig: mockSig(hashCode(contentId + 'reactsig' + i)),
})
}
return reactions
}
/**
* Get mock reaction counts for a content ID (quick lookup without generating all events)
*/
export function getMockReactionCounts(contentId: string): { positive: number; negative: number; total: number } {
const seed = Math.abs(hashCode(contentId))
const total = 5 + (seed % 11)
const positive = Math.floor(total * 0.8)
const negative = total - positive
return { positive, negative, total: positive - negative }
}
/**
* Get mock comment count for a content ID (quick lookup)
*/
export function getMockCommentCount(contentId: string): number {
const templates = commentTemplates[contentId]
if (templates) {
return 3 + Math.floor(Math.abs(hashCode(contentId)) % Math.min(3, templates.length))
}
return 3 + Math.floor(Math.abs(hashCode(contentId)) % 3)
}
/**
* Get a mock profile by pubkey
*/
export function getMockProfile(pubkey: string): MockProfile | undefined {
return mockProfiles.find((p) => pubkey.startsWith(p.pubkey.slice(0, 20)))
|| mockProfiles[Math.abs(hashCode(pubkey)) % mockProfiles.length]
}
/**
* Simple hash function for deterministic results from strings
*/
function hashCode(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash |= 0 // Convert to 32bit integer
}
return hash
}

60
src/data/testPersonas.ts Normal file
View File

@@ -0,0 +1,60 @@
/** Pre-generated test personas for local development */
export const TEST_PERSONAS = [
{
name: "Alice",
nsec: "nsec1uvp6k7pxn606t76agds6z0jlqudqr4eqet7g7l072mda7skyna8s4deymn",
pubkey: "72a28cbae9d9d3ff83ede49d854353ebec62b2870497e3c9586d7a390c7d5e22",
},
{
name: "Bob",
nsec: "nsec1t6rt4jagk670zvww09pwvyr8kr2kgxxwxezgjyzwsgny08evwfwsegswn2",
pubkey: "ddd9ccd087feaa230e0b7bfe4b2d20d8b9e60226303d1401bc6d32751fa7718c",
},
{
name: "Charlie",
nsec: "nsec1qfd60z5dr4tx5758vuu4y0gla6xnkq8w3fdlcnzvx2wzqhnk832qu7qe4h",
pubkey: "a1a19b736fe43cd6c52c56f22b5bfd62a54847daa041f82f0348a47dabdd6013",
},
{
name: "Diana",
nsec: "nsec1aenwtwuqcay9x4c4fludmddg77lxah6v5dnmrdv9fm8dum3tf5gq4x7m8g",
pubkey: "fe059843e48f7946a01d6053fc0f922cd8fddb1193af5db522b09da76a95da1f",
},
{
name: "Eve",
nsec: "nsec17jumf8q8rdf6tprh95w059gxyu3f5plrg22nauq0yc2sxs6pdq9s8pe0hf",
pubkey: "bc43365a5fb6c0387ac2fa93d6c1698b8aa674aa7ed34730f119c533a71ee817",
},
] as const
export const TASTEMAKER_PERSONAS = [
{
name: "Quentin",
nsec: "nsec1d26yskme360lemexx94njjq3zh296tuxuqqmhneqxrgy4qef3mxs739xcj",
pubkey: "36a615cb10a8ebba24a5817c66a0fdf334d8f8a48757a1ac6cf551f64bb13fab",
},
{
name: "Pauline",
nsec: "nsec1j3ljvyyjucja2qfgevfn856v9xmwwynm6qvyu7qjr4z586n09l2qtxjsha",
pubkey: "3d00a259892c553d9594255f2ba9d36644146fd71a314c8967d7e1f1f402413d",
},
{
name: "Martin",
nsec: "nsec1r958f0vmdrjy0wa6fl2wvyqmyufrfrd2wfspy4tjznp9qcj7u54stfxpjt",
pubkey: "b1ac46e38c20ae704d838ee2b099f1d3cb318ba329c4598ab5f5f4461384ca90",
},
{
name: "Agnes",
nsec: "nsec1j5fp54smy6vxdt69w39y25v9qcsewz8yketxsy7tqq5h650xft0s6jmc5f",
pubkey: "b694d84f6be95807d721ccbbf264883f51a3d5dfbef08ddb841a991ab245c4fe",
},
{
name: "Akira",
nsec: "nsec1r7szu5tastx20fqpnazr0cm7ual97zj3tjf8rratpf4us3n3fvtscpvrle",
pubkey: "7e11744d5da806f5664bf07edf82739eea4fc2db1502fa8c91a73b761aeaad65",
},
] as const
export const TASTEMAKER_PUBKEYS: string[] = TASTEMAKER_PERSONAS.map(
(p) => p.pubkey,
)

76
src/lib/accounts.ts Normal file
View File

@@ -0,0 +1,76 @@
import { AccountManager, Accounts } from 'applesauce-accounts'
import type { NostrEvent } from 'applesauce-core/helpers/event'
import { NostrConnectSigner } from 'applesauce-signers'
import { filter, map } from 'rxjs'
import { pool } from './relay'
const STORAGE_KEY = 'indeedhub-accounts'
const ACTIVE_KEY = 'indeedhub-active-account'
// Create singleton account manager
export const accountManager = new AccountManager()
// Register common account types (Extension, PrivateKey, NostrConnect, etc.)
Accounts.registerCommonAccountTypes(accountManager)
// Wire NostrConnect signer to use our relay pool
NostrConnectSigner.subscriptionMethod = (relays, filters) => {
return pool.subscription(relays, filters).pipe(
filter((res): res is NostrEvent => res !== 'EOSE'),
map((event) => event as NostrEvent),
)
}
NostrConnectSigner.publishMethod = (relays, event) => {
return pool.publish(relays, event)
}
/**
* Load saved accounts from localStorage
*/
export function loadAccounts() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const accounts = JSON.parse(saved)
accountManager.fromJSON(accounts, true)
// Restore active account
const activeId = localStorage.getItem(ACTIVE_KEY)
if (activeId) {
const account = accountManager.getAccount(activeId)
if (account) {
accountManager.setActive(account)
}
}
}
} catch (e) {
console.error('Failed to load accounts:', e)
}
}
/**
* Persist accounts to localStorage on changes
*/
export function setupPersistence() {
accountManager.accounts$.subscribe(() => {
try {
const json = accountManager.toJSON(true)
localStorage.setItem(STORAGE_KEY, JSON.stringify(json))
} catch (e) {
console.error('Failed to save accounts:', e)
}
})
accountManager.active$.subscribe((active) => {
if (active) {
localStorage.setItem(ACTIVE_KEY, active.id)
} else {
localStorage.removeItem(ACTIVE_KEY)
}
})
}
// Initialize on module load
loadAccounts()
setupPersistence()

View File

@@ -1,184 +1,46 @@
import { SimplePool, nip19, type Event as NostrEvent, type Filter } from 'nostr-tools'
import { nostrConfig } from '../config/api.config'
import { EventFactory, EventStore } from 'applesauce-core'
import {
createEventLoaderForStore,
createReactionsLoader,
} from 'applesauce-loaders/loaders'
/**
* Nostr Client
* Handles Nostr relay connections and event management
*/
class NostrClient {
private pool: SimplePool
private relays: string[]
private lookupRelays: string[]
private eventCache: Map<string, NostrEvent>
import { accountManager } from './accounts'
import { pool, APP_RELAYS, LOOKUP_RELAYS, appRelays } from './relay'
constructor() {
this.pool = new SimplePool()
this.relays = nostrConfig.relays
this.lookupRelays = nostrConfig.lookupRelays
this.eventCache = new Map()
// Re-export relay primitives so other modules can import from nostr.ts
export { pool, APP_RELAYS, LOOKUP_RELAYS, appRelays }
// Add blueprints for structured comment/reaction handling
import 'applesauce-common/blueprints/comment'
import 'applesauce-common/blueprints/reaction'
// Setup event store (in-memory cache of all seen events)
export const eventStore = new EventStore()
// Setup event factory with our app identity
export const factory = new EventFactory({
client: {
name: 'indeedhub-prototype',
address: {
identifier: 'indeedhub-prototype',
pubkey:
'0f193d51fd76ef21f870bcc94f5561df675b64260c7e2aaf79a816a8fd6cda3d',
},
},
})
// Sync signer from active account so factory can sign events
accountManager.active$.subscribe(async (account) => {
if (account) {
factory.setSigner(account.signer)
}
})
/**
* Subscribe to events with filters
*/
subscribe(
filters: Filter | Filter[],
onEvent: (event: NostrEvent) => void,
onEose?: () => void
) {
const filterArray = Array.isArray(filters) ? filters : [filters]
const sub = this.pool.subscribeMany(
this.relays,
filterArray as any, // Type workaround for nostr-tools
{
onevent: (event) => {
this.eventCache.set(event.id, event)
onEvent(event)
},
oneose: () => {
onEose?.()
},
}
)
// Setup event loader for fetching missing events (profiles, etc.)
createEventLoaderForStore(eventStore, pool, {
lookupRelays: LOOKUP_RELAYS,
extraRelays: APP_RELAYS,
})
return sub
}
/**
* Fetch events (one-time query)
*/
async fetchEvents(filters: Filter): Promise<NostrEvent[]> {
const events = await this.pool.querySync(this.relays, filters)
events.forEach((event) => {
this.eventCache.set(event.id, event)
})
return events
}
/**
* Publish event to relays
*/
async publishEvent(event: NostrEvent): Promise<void> {
const results = this.pool.publish(this.relays, event)
// Wait for at least one successful publish
await Promise.race(results)
this.eventCache.set(event.id, event)
}
/**
* Get profile metadata (kind 0)
*/
async getProfile(pubkey: string): Promise<NostrEvent | null> {
const events = await this.pool.querySync(this.lookupRelays, {
kinds: [0],
authors: [pubkey],
limit: 1,
})
return events[0] || null
}
/**
* Get comments for content (kind 1)
*/
async getComments(contentIdentifier: string): Promise<NostrEvent[]> {
const filter: Filter = {
kinds: [1],
'#i': [contentIdentifier],
}
return this.fetchEvents(filter)
}
/**
* Get reactions for content (kind 17)
*/
async getReactions(contentIdentifier: string): Promise<NostrEvent[]> {
const filter: Filter = {
kinds: [17],
'#i': [contentIdentifier],
}
return this.fetchEvents(filter)
}
/**
* Subscribe to comments in real-time
*/
subscribeToComments(
contentIdentifier: string,
onComment: (event: NostrEvent) => void,
onEose?: () => void
) {
return this.subscribe(
[{
kinds: [1],
'#i': [contentIdentifier],
since: Math.floor(Date.now() / 1000),
}],
onComment,
onEose
)
}
/**
* Subscribe to reactions in real-time
*/
subscribeToReactions(
contentIdentifier: string,
onReaction: (event: NostrEvent) => void,
onEose?: () => void
) {
return this.subscribe(
[{
kinds: [17],
'#i': [contentIdentifier],
since: Math.floor(Date.now() / 1000),
}],
onReaction,
onEose
)
}
/**
* Get event from cache or fetch
*/
async getEvent(eventId: string): Promise<NostrEvent | null> {
// Check cache first
if (this.eventCache.has(eventId)) {
return this.eventCache.get(eventId)!
}
// Fetch from relays
const events = await this.fetchEvents({ ids: [eventId] })
return events[0] || null
}
/**
* Close all connections
*/
close() {
this.pool.close(this.relays)
}
/**
* Convert npub to hex pubkey
*/
npubToHex(npub: string): string {
const decoded = nip19.decode(npub)
if (decoded.type === 'npub') {
return decoded.data
}
throw new Error('Invalid npub')
}
/**
* Convert hex pubkey to npub
*/
hexToNpub(hex: string): string {
return nip19.npubEncode(hex)
}
}
// Export singleton instance
export const nostrClient = new NostrClient()
// Reactions loader for fetching reactions on events
export const reactionsLoader = createReactionsLoader(pool, { eventStore })

19
src/lib/relay.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Relay pool and relay constants.
* Extracted into its own module to avoid circular dependencies
* between nostr.ts and accounts.ts.
*/
import { BehaviorSubject } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
// Relay pool for all WebSocket connections
export const pool = new RelayPool()
// App relays (local dev relay)
export const APP_RELAYS = ['ws://localhost:7777']
// Lookup relays for profile metadata
export const LOOKUP_RELAYS = ['wss://purplepag.es']
// Observable relay list for reactive subscriptions
export const appRelays = new BehaviorSubject<string[]>([...APP_RELAYS])

View File

@@ -14,9 +14,8 @@ const router = createRouter({
{
path: '/library',
name: 'library',
component: () => import('../views/Library.vue'),
beforeEnter: authGuard,
meta: { requiresAuth: true }
component: Browse,
meta: { requiresAuth: false }
},
{
path: '/profile',

View File

@@ -3,8 +3,6 @@
<SplashIntro />
<div class="browse-view">
<!-- Header / Navigation -->
<AppHeader @openAuth="showAuthModal = true" />
<!-- Hero / Featured Content -->
<section class="relative h-[56vh] md:h-[61vh] overflow-hidden">
@@ -59,66 +57,187 @@
</div>
</section>
<!-- Content Rows -->
<!-- Content Section -->
<section class="relative pt-8 pb-20 px-4">
<div class="mx-auto space-y-12">
<!-- Featured Films -->
<ContentRow
title="Featured Films"
:contents="featuredFilms"
@content-click="handleContentClick"
/>
<!-- ===== MY LIST TAB ===== -->
<template v-if="isMyListTab">
<!-- Not logged in -->
<div v-if="!isLoggedInAnywhere" class="text-center py-16">
<div class="text-white/50 text-lg mb-6">Sign in to save films to your list</div>
<button @click="$emit('openAuth')" class="hero-play-button inline-block">Sign In</button>
</div>
<!-- New Releases -->
<ContentRow
title="New Releases"
:contents="newReleases"
@content-click="handleContentClick"
/>
<!-- Logged in: library content -->
<template v-else>
<!-- Continue Watching -->
<div v-if="continueWatching.length > 0" class="content-row">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-6 px-4 uppercase">Continue Watching</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 px-4">
<div
v-for="item in continueWatching"
:key="item.content.id"
class="content-card group/card cursor-pointer"
@click="handleContentClick(item.content)"
>
<div class="glass-card rounded-lg p-1.5 transition-all duration-300 relative">
<img
:src="item.content.thumbnail"
:alt="item.content.title"
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
loading="lazy"
/>
<div class="absolute bottom-2 left-2 right-2 h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-white/80 rounded-full" :style="{ width: `${item.progress}%` }"></div>
</div>
</div>
<div class="mt-2">
<h3 class="text-sm md:text-base font-semibold text-white truncate">{{ item.content.title }}</h3>
</div>
</div>
</div>
</div>
<!-- Bitcoin & Crypto -->
<ContentRow
title="Bitcoin & Cryptocurrency"
:contents="bitcoinFilms"
@content-click="handleContentClick"
/>
<!-- Saved Films -->
<div v-if="myListContent.length > 0" class="content-row">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-6 px-4 uppercase">Saved Films</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 px-4">
<div
v-for="content in myListContent"
:key="content.id"
class="content-card group/card cursor-pointer"
@click="handleContentClick(content)"
>
<div class="glass-card rounded-lg p-1.5 transition-all duration-300">
<img
:src="content.thumbnail"
:alt="content.title"
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
loading="lazy"
/>
</div>
<div class="mt-2">
<h3 class="text-sm md:text-base font-semibold text-white truncate">{{ content.title }}</h3>
<p class="text-xs text-white/60 truncate hidden md:block">{{ content.description }}</p>
</div>
</div>
</div>
</div>
<!-- Documentaries -->
<ContentRow
title="Documentaries"
:contents="documentaries"
@content-click="handleContentClick"
/>
<!-- Rentals -->
<div v-if="rentedContent.length > 0" class="content-row">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-6 px-4 uppercase">My Rentals</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 px-4">
<div
v-for="content in rentedContent"
:key="content.id"
class="content-card group/card cursor-pointer"
@click="handleContentClick(content)"
>
<div class="glass-card rounded-lg p-1.5 transition-all duration-300 relative">
<img
:src="content.thumbnail"
:alt="content.title"
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
loading="lazy"
/>
<div class="absolute top-3 right-3 bg-black/70 backdrop-blur-md px-2 py-0.5 rounded-full text-xs text-white/80">
48h left
</div>
</div>
<div class="mt-2">
<h3 class="text-sm md:text-base font-semibold text-white truncate">{{ content.title }}</h3>
</div>
</div>
</div>
</div>
<!-- Independent Cinema -->
<ContentRow
title="Independent Cinema"
:contents="independentCinema"
@content-click="handleContentClick"
/>
<!-- Empty library -->
<div v-if="continueWatching.length === 0 && myListContent.length === 0 && rentedContent.length === 0" class="text-center py-16">
<div class="text-white/50 text-lg mb-6">Your list is empty</div>
<router-link to="/" class="hero-play-button inline-block text-decoration-none">Browse Films</router-link>
</div>
</template>
</template>
<!-- Dramas -->
<ContentRow
title="Drama Films"
:contents="dramas"
@content-click="handleContentClick"
/>
<!-- ===== FILMS TAB: Filtered Grid ===== -->
<template v-else-if="isFilterActive">
<div class="content-row">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-6 px-4 uppercase">
{{ activeAlgorithmLabel }}
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 px-4">
<div
v-for="content in filteredContent"
:key="content.id"
class="content-card group/card cursor-pointer"
@click="handleContentClick(content)"
>
<div class="glass-card rounded-lg p-1.5 transition-all duration-300">
<img
:src="content.thumbnail"
:alt="content.title"
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
loading="lazy"
/>
</div>
<div class="mt-2">
<h3 class="text-sm md:text-base font-semibold text-white truncate">{{ content.title }}</h3>
<p class="text-xs text-white/60 truncate hidden md:block">{{ content.description }}</p>
</div>
</div>
</div>
</div>
</template>
<!-- ===== FILMS TAB: Default Category Rows ===== -->
<template v-else>
<ContentRow
title="Featured Films"
:contents="featuredFilms"
@content-click="handleContentClick"
/>
<ContentRow
title="New Releases"
:contents="newReleases"
@content-click="handleContentClick"
/>
<ContentRow
title="Bitcoin & Cryptocurrency"
:contents="bitcoinContent"
@content-click="handleContentClick"
/>
<ContentRow
title="Documentaries"
:contents="documentaryContent"
@content-click="handleContentClick"
/>
<ContentRow
title="Independent Cinema"
:contents="independentCinema"
@content-click="handleContentClick"
/>
<ContentRow
title="Drama Films"
:contents="dramas"
@content-click="handleContentClick"
/>
</template>
</div>
</section>
<!-- Modals -->
<AuthModal
:isOpen="showAuthModal"
@close="showAuthModal = false"
@success="handleAuthSuccess"
/>
<ContentDetailModal
:isOpen="showDetailModal"
:content="selectedContent"
@close="showDetailModal = false"
@openAuth="showAuthModal = true"
@openAuth="$emit('openAuth')"
/>
<!-- Hero-only modals (for direct Play button) -->
@@ -137,45 +256,124 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import ContentRow from '../components/ContentRow.vue'
import SplashIntro from '../components/SplashIntro.vue'
import AppHeader from '../components/AppHeader.vue'
import AuthModal from '../components/AuthModal.vue'
import ContentDetailModal from '../components/ContentDetailModal.vue'
import SubscriptionModal from '../components/SubscriptionModal.vue'
import VideoPlayer from '../components/VideoPlayer.vue'
import { useContentStore } from '../stores/content'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useContentDiscovery } from '../composables/useContentDiscovery'
import { indeeHubFilms, bitcoinFilms, documentaries } from '../data/indeeHubFilms'
import type { Content } from '../types/content'
const emit = defineEmits<{ (e: 'openAuth'): void }>()
const route = useRoute()
const contentStore = useContentStore()
const { isAuthenticated, hasActiveSubscription } = useAuth()
const { isLoggedIn: isNostrLoggedIn } = useAccounts()
const { sortContent, isFilterActive, activeAlgorithmLabel } = useContentDiscovery()
const featuredContent = computed(() => contentStore.featuredContent)
// Determine active tab from route path
const isMyListTab = computed(() => route.path === '/library')
// Auth: either app auth or Nostr
const isLoggedInAnywhere = computed(() => isAuthenticated.value || isNostrLoggedIn.value)
// ===== FILMS TAB DATA =====
// The "continue watching" item shown in the hero on My List
const resumeItem = computed(() => continueWatching.value[0] ?? null)
// Hero banner: resume film on My List, top-ranked on filter, or default
const featuredContent = computed(() => {
if (isMyListTab.value && isLoggedInAnywhere.value && resumeItem.value) {
return resumeItem.value.content
}
if (isFilterActive.value && filteredContent.value.length > 0) {
return filteredContent.value[0]
}
return contentStore.featuredContent
})
// Default category rows (unfiltered)
const featuredFilms = computed(() => contentStore.contentRows.featured)
const newReleases = computed(() => contentStore.contentRows.newReleases)
const bitcoinFilms = computed(() => contentStore.contentRows.bitcoin)
const bitcoinContent = computed(() => contentStore.contentRows.bitcoin)
const independentCinema = computed(() => contentStore.contentRows.independent)
const dramas = computed(() => contentStore.contentRows.dramas)
const documentaries = computed(() => contentStore.contentRows.documentaries)
const documentaryContent = computed(() => contentStore.contentRows.documentaries)
// Deduplicated + sorted content for filter grid view
const filteredContent = computed(() => {
const rows = contentStore.contentRows
const seen = new Set<string>()
const allContent: Content[] = []
for (const row of Object.values(rows)) {
for (const item of row) {
if (!seen.has(item.id)) {
seen.add(item.id)
allContent.push(item)
}
}
}
return sortContent.value(allContent)
})
// ===== MY LIST TAB DATA =====
const continueWatching = ref<Array<{ content: Content; progress: number }>>([])
const myListContent = ref<Content[]>([])
const rentedContent = ref<Content[]>([])
/**
* Populate library with dummy data from the film catalog.
* In production this would come from the API.
*/
function loadDummyLibrary() {
if (myListContent.value.length > 0) return // Already loaded
continueWatching.value = indeeHubFilms.slice(0, 3).map((film) => ({
content: film,
progress: Math.floor(Math.random() * 70) + 10,
}))
myListContent.value = [
...bitcoinFilms.slice(0, 3),
...indeeHubFilms.slice(5, 8),
]
rentedContent.value = documentaries.slice(0, 2)
}
// Load library data when the tab becomes active and user is logged in
watch([isMyListTab, isLoggedInAnywhere], ([onListTab, loggedIn]) => {
if (onListTab && loggedIn) {
loadDummyLibrary()
}
}, { immediate: true })
// ===== SHARED =====
const showAuthModal = ref(false)
const showDetailModal = ref(false)
const showSubscriptionModal = ref(false)
const showVideoPlayer = ref(false)
const selectedContent = ref<Content | null>(null)
// Content card click -> always open detail modal
const handleContentClick = (content: Content) => {
selectedContent.value = content
showDetailModal.value = true
}
// Hero Play button -> direct play flow (skips detail modal)
const handlePlayClick = () => {
if (!isAuthenticated.value) {
showAuthModal.value = true
emit('openAuth')
return
}
@@ -185,20 +383,14 @@ const handlePlayClick = () => {
return
}
// No subscription - show subscription modal
showSubscriptionModal.value = true
}
// Hero More Info button -> open detail modal for featured content
const handleInfoClick = () => {
selectedContent.value = featuredContent.value
showDetailModal.value = true
}
function handleAuthSuccess() {
showAuthModal.value = false
}
function handleSubscriptionSuccess() {
showSubscriptionModal.value = false
}
@@ -243,6 +435,7 @@ onMounted(() => {
transition: all 0.3s ease;
white-space: nowrap;
letter-spacing: 0.02em;
text-decoration: none;
}
.hero-play-button::before {
@@ -319,4 +512,28 @@ onMounted(() => {
font-size: 16px;
}
}
/* Content section styles */
.content-row-title {
background: linear-gradient(to right, #fafafa, #9ca3af);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.05em;
}
.glass-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.group\/card:hover .glass-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2), 0 0 20px rgba(255, 255, 255, 0.05);
transform: translateY(-4px);
}
</style>

View File

@@ -1,217 +0,0 @@
<template>
<div class="library-view">
<!-- Header -->
<AppHeader @openAuth="showAuthModal = true" />
<!-- Main Content -->
<main class="pt-24 pb-20 px-4">
<div class="mx-auto" style="max-width: 75%">
<!-- Page Title -->
<h1 class="text-4xl md:text-5xl font-bold text-white mb-8">My Library</h1>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-20">
<div class="text-white/60">Loading your library...</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-20">
<div class="text-red-400">{{ error }}</div>
</div>
<!-- Content -->
<div v-else class="space-y-12">
<!-- Continue Watching -->
<section v-if="continueWatching.length > 0">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 uppercase">Continue Watching</h2>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div
v-for="item in continueWatching"
:key="item.content.id"
class="content-card cursor-pointer"
@click="openDetail(item.content)"
>
<div class="relative">
<img
:src="item.content.thumbnail"
:alt="item.content.title"
class="w-full aspect-[2/3] object-cover rounded-lg"
/>
<!-- Progress Bar -->
<div class="absolute bottom-0 left-0 right-0 h-1 bg-white/20 rounded-b-lg overflow-hidden">
<div class="h-full bg-orange-500" :style="{ width: `${item.progress}%` }"></div>
</div>
</div>
<h3 class="mt-2 text-sm font-medium text-white truncate">{{ item.content.title }}</h3>
</div>
</div>
</section>
<!-- Rented Content -->
<section v-if="rentedContent.length > 0">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 uppercase">My Rentals</h2>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div
v-for="rental in rentedContent"
:key="rental.id"
class="content-card cursor-pointer"
@click="rental.mappedContent && openDetail(rental.mappedContent)"
>
<div class="relative">
<img
:src="rental.mappedContent?.thumbnail"
:alt="rental.mappedContent?.title"
class="w-full aspect-[2/3] object-cover rounded-lg"
/>
<!-- Rental Expiry Badge -->
<div class="absolute top-2 right-2 bg-black/80 backdrop-blur-md px-2 py-1 rounded text-xs text-white/80">
{{ formatTimeRemaining(rental.expiresAt) }}
</div>
</div>
<h3 class="mt-2 text-sm font-medium text-white truncate">{{ rental.mappedContent?.title }}</h3>
</div>
</div>
</section>
<!-- Subscribed Content (if has subscription) -->
<section v-if="hasSubscription && subscribedContent.length > 0">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 uppercase">All Content</h2>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div
v-for="content in subscribedContent"
:key="content.id"
class="content-card cursor-pointer"
@click="openDetail(content)"
>
<img
:src="content.thumbnail"
:alt="content.title"
class="w-full aspect-[2/3] object-cover rounded-lg"
/>
<h3 class="mt-2 text-sm font-medium text-white truncate">{{ content.title }}</h3>
</div>
</div>
</section>
<!-- Empty State -->
<section v-if="continueWatching.length === 0 && rentedContent.length === 0 && subscribedContent.length === 0" class="text-center py-20">
<div class="text-white/60 mb-6">Your library is empty</div>
<router-link to="/" class="hero-play-button inline-block">Browse Content</router-link>
</section>
</div>
</div>
</main>
<!-- Content Detail Modal -->
<ContentDetailModal
:isOpen="showDetailModal"
:content="selectedContent"
@close="showDetailModal = false"
@openAuth="showAuthModal = true"
/>
<!-- Auth Modal -->
<AuthModal
:isOpen="showAuthModal"
@close="showAuthModal = false"
@success="showAuthModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuth } from '../composables/useAuth'
import { libraryService } from '../services/library.service'
import { mapApiContentsToContents } from '../utils/mappers'
import type { Content } from '../types/content'
import AppHeader from '../components/AppHeader.vue'
import ContentDetailModal from '../components/ContentDetailModal.vue'
import AuthModal from '../components/AuthModal.vue'
const { hasActiveSubscription } = useAuth()
interface MappedRental {
id: string
expiresAt: string
mappedContent?: Content
}
const continueWatching = ref<Array<{ content: Content; progress: number }>>([])
const rentedContent = ref<MappedRental[]>([])
const subscribedContent = ref<Content[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const showDetailModal = ref(false)
const showAuthModal = ref(false)
const selectedContent = ref<Content | null>(null)
const hasSubscription = computed(() => hasActiveSubscription.value)
onMounted(async () => {
await fetchLibrary()
})
async function fetchLibrary() {
isLoading.value = true
error.value = null
try {
const library = await libraryService.getUserLibrary()
continueWatching.value = library.continueWatching.map((item) => ({
content: mapApiContentsToContents([item.content])[0],
progress: item.progress,
}))
rentedContent.value = library.rented.map((rent) => ({
id: rent.id,
expiresAt: rent.expiresAt,
mappedContent: rent.content ? mapApiContentsToContents([rent.content])[0] : undefined,
}))
subscribedContent.value = mapApiContentsToContents(library.subscribed)
} catch (err: any) {
error.value = err.message || 'Failed to load library'
console.error('Library fetch error:', err)
} finally {
isLoading.value = false
}
}
function openDetail(content: Content) {
selectedContent.value = content
showDetailModal.value = true
}
function formatTimeRemaining(expiresAt: string): string {
const now = new Date()
const expires = new Date(expiresAt)
const diff = expires.getTime() - now.getTime()
if (diff < 0) return 'Expired'
const hours = Math.floor(diff / (1000 * 60 * 60))
if (hours < 24) {
return `${hours}h left`
}
const days = Math.floor(hours / 24)
return `${days}d left`
}
</script>
<style scoped>
.library-view {
min-height: 100vh;
background: #0a0a0a;
}
.content-card {
transition: transform 0.3s ease;
}
.content-card:hover {
transform: scale(1.05);
}
</style>

View File

@@ -1,8 +1,5 @@
<template>
<div class="profile-view">
<!-- Header -->
<AppHeader @openAuth="showAuthModal = true" />
<!-- Main Content -->
<main class="pt-24 pb-20 px-4">
<div class="mx-auto max-w-4xl">
@@ -112,28 +109,18 @@
</div>
</main>
<!-- Auth Modal -->
<AuthModal
:isOpen="showAuthModal"
@close="showAuthModal = false"
@success="showAuthModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter as _useRouter } from 'vue-router'
import { useAuth } from '../composables/useAuth'
import { subscriptionService } from '../services/subscription.service'
import type { ApiSubscription } from '../types/api'
import AppHeader from '../components/AppHeader.vue'
import AuthModal from '../components/AuthModal.vue'
const { user, linkNostr, unlinkNostr } = useAuth()
const subscription = ref<ApiSubscription | null>(null)
const showAuthModal = ref(false)
// Get subscription from user data directly
const subscriptionFromUser = computed(() => {

View File

@@ -1 +1 @@
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useaccess.ts","./src/composables/useauth.ts","./src/composables/usemobile.ts","./src/composables/usenostr.ts","./src/composables/usetoast.ts","./src/config/api.config.ts","./src/data/indeehubfilms.ts","./src/data/mocksocialdata.ts","./src/lib/nostr.ts","./src/router/guards.ts","./src/router/index.ts","./src/services/api.service.ts","./src/services/auth.service.ts","./src/services/content.service.ts","./src/services/library.service.ts","./src/services/subscription.service.ts","./src/stores/auth.ts","./src/stores/content.ts","./src/types/api.ts","./src/types/content.ts","./src/utils/indeehubapi.ts","./src/utils/mappers.ts","./src/utils/nostr.ts","./src/app.vue","./src/components/appheader.vue","./src/components/authmodal.vue","./src/components/contentdetailmodal.vue","./src/components/contentrow.vue","./src/components/mobilenav.vue","./src/components/rentalmodal.vue","./src/components/splashintro.vue","./src/components/subscriptionmodal.vue","./src/components/toastcontainer.vue","./src/components/videoplayer.vue","./src/views/browse.vue","./src/views/library.vue","./src/views/profile.vue"],"version":"5.9.3"}
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useaccess.ts","./src/composables/useaccounts.ts","./src/composables/useauth.ts","./src/composables/usemobile.ts","./src/composables/usenostr.ts","./src/composables/useobservable.ts","./src/composables/usetoast.ts","./src/config/api.config.ts","./src/data/indeehubfilms.ts","./src/data/testpersonas.ts","./src/lib/accounts.ts","./src/lib/nostr.ts","./src/lib/relay.ts","./src/router/guards.ts","./src/router/index.ts","./src/services/api.service.ts","./src/services/auth.service.ts","./src/services/content.service.ts","./src/services/library.service.ts","./src/services/subscription.service.ts","./src/stores/auth.ts","./src/stores/content.ts","./src/types/api.ts","./src/types/content.ts","./src/utils/indeehubapi.ts","./src/utils/mappers.ts","./src/utils/nostr.ts","./src/app.vue","./src/components/appheader.vue","./src/components/authmodal.vue","./src/components/contentdetailmodal.vue","./src/components/contentrow.vue","./src/components/mobilenav.vue","./src/components/rentalmodal.vue","./src/components/splashintro.vue","./src/components/splashintroicon.vue","./src/components/subscriptionmodal.vue","./src/components/toastcontainer.vue","./src/components/videoplayer.vue","./src/views/browse.vue","./src/views/library.vue","./src/views/profile.vue"],"version":"5.9.3"}