Initial commit

This commit is contained in:
Trey
2026-03-20 18:49:48 -05:00
commit dfa1697fef
197 changed files with 29298 additions and 0 deletions

7
web/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.gitignore
*.md
.DS_Store
server/data

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

26
web/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# Frontend Dockerfile - Vite React App
FROM node:20-alpine
WORKDIR /app
# Copy package files first (for better caching when only code changes)
COPY package*.json ./
# Install dependencies to a cache location (will be copied to volume on start)
RUN npm ci && cp -r node_modules /node_modules_cache
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Copy source code
COPY . .
# Expose Vite dev server port
EXPOSE 5173
# Use entrypoint to initialize node_modules volume if empty
ENTRYPOINT ["docker-entrypoint.sh"]
# Run Vite dev server (accessible from outside container)
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

73
web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

4
web/data/auth.json Executable file
View File

@@ -0,0 +1,4 @@
{
"username": "admin",
"password": "feeld123"
}

View File

@@ -0,0 +1,339 @@
{
"profiles": [
{
"id": "profile#60014f96-172a-4a84-9011-bd41660c2414",
"imaginaryName": "S",
"age": 30,
"gender": "WOMAN",
"sexuality": "QUEER",
"photos": [
{
"__typename": "Picture",
"id": "picture|profile#60014f96-172a-4a84-9011-bd41660c2414-21400b7a-edb4-473c-828e-59c8c1bfb8e3",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "DEFAULT",
"pictureUrl": "https://res.cloudinary.com/threender/image/upload/6251164c-f62c-418d-8a6c-85dbc7adfaef?_a=BAMAK+TI0",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/6251164c-f62c-418d-8a6c-85dbc7adfaef?__cld_token__=exp=1769725742~hmac=a1fdb730ebcf881b2d2249de905a0fbff8f015af06bb9bcea723740056f2a22e&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/6251164c-f62c-418d-8a6c-85dbc7adfaef?__cld_token__=exp=1769725742~hmac=e9353e2f65fcadd35e8b2df765595b6578d60dc1be974f4607338966b2236ccb&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/6251164c-f62c-418d-8a6c-85dbc7adfaef?__cld_token__=exp=1769725742~hmac=b43ca702194f72e9339c40388a6b352493422540b8b9d965728b4efffeed15ae&_a=BAMAK+TI0"
},
"publicId": "6251164c-f62c-418d-8a6c-85dbc7adfaef",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
},
{
"__typename": "Picture",
"id": "picture|profile#60014f96-172a-4a84-9011-bd41660c2414-b0ad610f-95d8-4ead-b9f2-b8a91344d082",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "SECONDARY",
"pictureUrl": "https://res.cloudinary.com/threender/image/upload/2fdebae0-7f64-468a-828b-04eb9faafecf?_a=BAMAK+TI0",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/2fdebae0-7f64-468a-828b-04eb9faafecf?__cld_token__=exp=1769725742~hmac=20495a07255cdb4dfc3ccaacb3389b06bd6e43dfac9540b368a33770d4fc933c&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/2fdebae0-7f64-468a-828b-04eb9faafecf?__cld_token__=exp=1769725742~hmac=6b219d1d3ff8d14088005bdceccbafe9f7db3f6ae843b4af0f955ad99c4ecde5&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/2fdebae0-7f64-468a-828b-04eb9faafecf?__cld_token__=exp=1769725742~hmac=5c34b10d8d4c961b28620a244840e6bafaa8db091ab4db5068bd0b3612285e45&_a=BAMAK+TI0"
},
"publicId": "2fdebae0-7f64-468a-828b-04eb9faafecf",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
},
{
"__typename": "Picture",
"id": "picture|profile#60014f96-172a-4a84-9011-bd41660c2414-99ae42b4-4ed6-4789-8538-ffc3e166f9ce",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "SECONDARY",
"pictureUrl": "https://res.cloudinary.com/threender/image/upload/16947427-6b86-481c-80c5-c4a510c4321a?_a=BAMAK+TI0",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/16947427-6b86-481c-80c5-c4a510c4321a?__cld_token__=exp=1769725742~hmac=704ff76529d023cf0f84f4b2414b2e05f5b427adfc70a8df93407e6b214a074c&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/16947427-6b86-481c-80c5-c4a510c4321a?__cld_token__=exp=1769725742~hmac=f4f84b204fd09cffddd404b0aeb1531e655f1cec095e69da2ad25f55a077894c&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/16947427-6b86-481c-80c5-c4a510c4321a?__cld_token__=exp=1769725742~hmac=fc0701b0228dd4fa14312b2fae9ec33d9055e5afe471efe236347d896f4eda7c&_a=BAMAK+TI0"
},
"publicId": "16947427-6b86-481c-80c5-c4a510c4321a",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
},
{
"__typename": "Picture",
"id": "picture|profile#60014f96-172a-4a84-9011-bd41660c2414-36ac3f96-3c35-4741-8db1-01024ea2721f",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "SECONDARY",
"pictureUrl": "https://res.cloudinary.com/threender/image/upload/fa6aa8c2-14c1-4d6a-a450-10dfbc588d89?_a=BAMAK+TI0",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/fa6aa8c2-14c1-4d6a-a450-10dfbc588d89?__cld_token__=exp=1769725742~hmac=de05f1a9ec1df7af6985d5962e4bd48702a406a6aa62135a181c5eb9064d7993&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/fa6aa8c2-14c1-4d6a-a450-10dfbc588d89?__cld_token__=exp=1769725742~hmac=715503ac70896691e6a4fb676b2829047e4d49ee887f5d850ddd926e21157656&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/fa6aa8c2-14c1-4d6a-a450-10dfbc588d89?__cld_token__=exp=1769725742~hmac=f3e05604df8328f4345fc1f9e52b0150087d9831bfcb74add0a3e4090e3bfd51&_a=BAMAK+TI0"
},
"publicId": "fa6aa8c2-14c1-4d6a-a450-10dfbc588d89",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
}
],
"dislikedAt": "2026-01-28T22:29:10.956Z"
},
{
"id": "profile#99b369ce-5503-5c5e-b6bc-ee0f6816a90f",
"imaginaryName": "Rich",
"age": 35,
"gender": "MAN",
"sexuality": "HETEROFLEXIBLE",
"photos": [
{
"id": "picture|profile#99b369ce-5503-5c5e-b6bc-ee0f6816a90f-a44496e0-ffa1-54d5-9744-9c34a11d0098",
"pictureUrls": {
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/e417170a-add4-436b-89fb-fd031a56fcf2?__cld_token__=exp=1769723855~hmac=6ccb6805df3ef6d5aa1dd8877017f5fe618adbaaf57bc93fe19839cd6cb182b7&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/e417170a-add4-436b-89fb-fd031a56fcf2?__cld_token__=exp=1769723855~hmac=73b218f7ccd186fd78d8142cb0857423f3163bc27cba1ba39ee79275c610b39a&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/e417170a-add4-436b-89fb-fd031a56fcf2?__cld_token__=exp=1769723855~hmac=5111bfc7e49ce84e2c4b7bd5b3ef7af0893d368f97670d5a542a2e6f9c7ef119&_a=BAMAK+TI0",
"__typename": "PictureUrls"
},
"pictureType": "DEFAULT",
"__typename": "Picture"
},
{
"id": "picture|profile#99b369ce-5503-5c5e-b6bc-ee0f6816a90f-6109d0ac-1f61-4c63-ab7c-c62d2cbd5df2",
"pictureUrls": {
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/ea4297e6-8a8c-4139-8937-afe422ebaf1a?__cld_token__=exp=1769723855~hmac=ac3bc443ec16a3dabdb0d3e0bb350a78b587b2d35b2afaf6680a74b2886bd9a2&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/ea4297e6-8a8c-4139-8937-afe422ebaf1a?__cld_token__=exp=1769723855~hmac=73534bdc1b763331e8bac11fb638f5143874ecbdd3c9d04d9ad6c90a961a6982&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/ea4297e6-8a8c-4139-8937-afe422ebaf1a?__cld_token__=exp=1769723855~hmac=72d2b2d52e07866664bc8e8f39801545a9a73bd63e263fa08766d765529bce6f&_a=BAMAK+TI0",
"__typename": "PictureUrls"
},
"pictureType": "SECONDARY",
"__typename": "Picture"
}
],
"dislikedAt": "2026-01-28T21:59:43.205Z"
},
{
"id": "profile#d1e78e7d-af87-4281-869c-26b4f0743f6d",
"imaginaryName": "Cameron",
"age": 29,
"gender": "MAN",
"sexuality": "BISEXUAL",
"photos": [
{
"__typename": "Picture",
"id": "picture|profile#d1e78e7d-af87-4281-869c-26b4f0743f6d-50a499bb-e89d-468a-91bb-00e97e1acf68",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "DEFAULT",
"pictureUrl": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/ffb9317b-c348-4c5b-8ec2-f36e8eeab01a/small",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/ffb9317b-c348-4c5b-8ec2-f36e8eeab01a/small?exp=1769638055&sig=5e8ec37f9afcd044931f49a9787d562777248960b791778d646961e7308b869d",
"medium": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/ffb9317b-c348-4c5b-8ec2-f36e8eeab01a/medium?exp=1769638055&sig=714c3cfff74a5f80baa2be308be4c72f6334af118581c02e73e80f4a7da3a254",
"large": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/ffb9317b-c348-4c5b-8ec2-f36e8eeab01a/large?exp=1769638055&sig=3fce64b30d462d02423a185ba7c098d0f352baa7614bcc909cf344d89d4cee34"
},
"publicId": "ffb9317b-c348-4c5b-8ec2-f36e8eeab01a",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
},
{
"__typename": "Picture",
"id": "picture|profile#d1e78e7d-af87-4281-869c-26b4f0743f6d-d35e99f5-5fe6-4ec5-84b6-39fa637b24d0",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "SECONDARY",
"pictureUrl": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/da57d8ed-16f0-4b76-849e-e1ee11e35a3f/small",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/da57d8ed-16f0-4b76-849e-e1ee11e35a3f/small?exp=1769638055&sig=f85cc376c50e8b8afe2b7602b8ad270640b104c984919b865d165ce7712a7693",
"medium": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/da57d8ed-16f0-4b76-849e-e1ee11e35a3f/medium?exp=1769638055&sig=dfaacb5d3d3eeb80e50b3578c67c19351aa065d072d80dd50de0606d51262410",
"large": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/da57d8ed-16f0-4b76-849e-e1ee11e35a3f/large?exp=1769638055&sig=afe82c8a906546a459ccf07f97d38bfdd1e182f24cdbc6d220e574ce0e58dfff"
},
"publicId": "da57d8ed-16f0-4b76-849e-e1ee11e35a3f",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
},
{
"__typename": "Picture",
"id": "picture|profile#d1e78e7d-af87-4281-869c-26b4f0743f6d-4d0412ae-b7c5-449d-a0cc-29dfe82a8af9",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "SECONDARY",
"pictureUrl": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/33973f22-f456-4258-961d-013a83ba5532/small",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/33973f22-f456-4258-961d-013a83ba5532/small?exp=1769638055&sig=e2651b156c7a94c4796448624e2c6c1dc1415717e7f6b42d28eaeb0265100c9f",
"medium": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/33973f22-f456-4258-961d-013a83ba5532/medium?exp=1769638055&sig=d697f5820718bad434eed5d7d72631c8b9acb1ddea96d63e60c93b72cbad93db",
"large": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/33973f22-f456-4258-961d-013a83ba5532/large?exp=1769638055&sig=3fea70c7165ac127a49add8b281d85535c5598e6075d962a2172e298282bc9ee"
},
"publicId": "33973f22-f456-4258-961d-013a83ba5532",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
},
{
"__typename": "Picture",
"id": "picture|profile#d1e78e7d-af87-4281-869c-26b4f0743f6d-4c4f9a9b-ae4e-47fe-821c-0388ec389c7e",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "SECONDARY",
"pictureUrl": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/7f9f1c15-9fd0-4858-8431-68e875127c8a/small",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/7f9f1c15-9fd0-4858-8431-68e875127c8a/small?exp=1769638055&sig=5379c8086bd883b0290d720bdeeecf7f81596685763716ef900ebcf7d9cceace",
"medium": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/7f9f1c15-9fd0-4858-8431-68e875127c8a/medium?exp=1769638055&sig=396f519a142ef620fad99ee7c644366b1ab7fa1cdeb0d995227b742ae5f89968",
"large": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/7f9f1c15-9fd0-4858-8431-68e875127c8a/large?exp=1769638055&sig=1a2eece72164f09c02865911a44e9f8072a0fc5e886d39bdfcfcb62f92abfcf2"
},
"publicId": "7f9f1c15-9fd0-4858-8431-68e875127c8a",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
},
{
"__typename": "Picture",
"id": "picture|profile#d1e78e7d-af87-4281-869c-26b4f0743f6d-41a43538-c433-4864-91e0-15356f646b49",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "SECONDARY",
"pictureUrl": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/139c5920-d83f-42be-860c-c41f7d92a8d9/small",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/139c5920-d83f-42be-860c-c41f7d92a8d9/small?exp=1769638055&sig=7e11e23b71875c4a26aeb3ebf69ee0790ec8a63972dd04f193c9b6c0059c150e",
"medium": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/139c5920-d83f-42be-860c-c41f7d92a8d9/medium?exp=1769638055&sig=dde62f41fd669249ca4c5856dc1a5e0e8917c4cee0f5fc850a1acd3bb6197289",
"large": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/139c5920-d83f-42be-860c-c41f7d92a8d9/large?exp=1769638055&sig=caf973d68b45e70d9b308bf7a0c6f14941b41a38eb4045c8bb7fb587018e141f"
},
"publicId": "139c5920-d83f-42be-860c-c41f7d92a8d9",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
},
{
"__typename": "Picture",
"id": "picture|profile#d1e78e7d-af87-4281-869c-26b4f0743f6d-df06e9f9-b6c4-4465-815b-38d73254d861",
"pictureIsPrivate": false,
"pictureIsSafe": true,
"pictureStatus": "READY",
"pictureType": "SECONDARY",
"pictureUrl": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/6c86b31b-a176-4169-b41d-0c8c2dbe618e/small",
"pictureUrls": {
"__typename": "PictureUrls",
"small": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/6c86b31b-a176-4169-b41d-0c8c2dbe618e/small?exp=1769638055&sig=a3ab98239eef2f05ce9aa1c6a6433679887318588b1be0b84b96865993952a92",
"medium": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/6c86b31b-a176-4169-b41d-0c8c2dbe618e/medium?exp=1769638055&sig=67bab558786d6ca5ca370377cb34ed25a36b791a64536dd7218a4816439eb6e4",
"large": "https://prod.fldcdn.com/gD4HJFgC49GIG8z1JveKRg/fld/6c86b31b-a176-4169-b41d-0c8c2dbe618e/large?exp=1769638055&sig=73f8dc604a66346ff17bb64b651ee9c53f3c6f2492b61597385c8bd6a35aff17"
},
"publicId": "6c86b31b-a176-4169-b41d-0c8c2dbe618e",
"verification": {
"__typename": "PictureVerification",
"status": "UNVERIFIED"
}
}
],
"dislikedAt": "2026-01-28T21:34:57.676Z"
},
{
"id": "profile#d254700f-ee2d-4765-9218-85dd61069bd5",
"imaginaryName": "Ryan",
"age": 32,
"gender": "MAN",
"sexuality": "BISEXUAL",
"photos": [
{
"id": "picture|profile#d254700f-ee2d-4765-9218-85dd61069bd5-92104c3c-964e-4e66-83c8-45c0662b8635",
"pictureUrls": {
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/757d850a-4012-4e42-bcf3-0e6cc3bcdadc?__cld_token__=exp=1769713481~hmac=69f2edc1e2c9f78bbcbb12fc3be8ee32fcd1fe07fefb72fd41d46a82d8ee87cf&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/757d850a-4012-4e42-bcf3-0e6cc3bcdadc?__cld_token__=exp=1769713481~hmac=d1558c2c875738b0c5970c0c0e1ff58925f7dc77944bd7c4e6b8ee819d8b6aaf&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/757d850a-4012-4e42-bcf3-0e6cc3bcdadc?__cld_token__=exp=1769713481~hmac=0bdcdea5c099d378e9e93be86d9a16b314ace32474549b2821da207208b8d4d5&_a=BAMAK+TI0",
"__typename": "PictureUrls"
},
"pictureType": "DEFAULT",
"__typename": "Picture"
},
{
"id": "picture|profile#d254700f-ee2d-4765-9218-85dd61069bd5-80e61b36-ac8d-425a-bbc4-f551210bb5a1",
"pictureUrls": {
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/47efa56c-5b65-46a0-8103-7176811f27fd?__cld_token__=exp=1769713481~hmac=018036a09b82acd173de8bc0732846caaed85fb16315864a3ae10c7ccd6e04a5&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/47efa56c-5b65-46a0-8103-7176811f27fd?__cld_token__=exp=1769713481~hmac=68a77f5b060d52503c6c2e325d39e33d4e7ae2e55cac81a34d3804bd46e0bff7&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/47efa56c-5b65-46a0-8103-7176811f27fd?__cld_token__=exp=1769713481~hmac=eaf7e06bb933d2c250aa531bb2e8a7e2aa3ed0e902d9f0e3b1ca5578c0f5dc7f&_a=BAMAK+TI0",
"__typename": "PictureUrls"
},
"pictureType": "SECONDARY",
"__typename": "Picture"
},
{
"id": "picture|profile#d254700f-ee2d-4765-9218-85dd61069bd5-12ee8df1-9b60-40ab-be91-0baaadbb7580",
"pictureUrls": {
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/e455eb70-5006-4141-b696-03175792926f?__cld_token__=exp=1769713481~hmac=bc9ef1d4f0bb850e3b2583b4c24806e01ed8b828f04deb60a09d2aaa9af549af&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/e455eb70-5006-4141-b696-03175792926f?__cld_token__=exp=1769713481~hmac=a63d516f28b66549b7ddcd2ad3fed5120ea63109fa594ce46b72983f50dd4cd9&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/e455eb70-5006-4141-b696-03175792926f?__cld_token__=exp=1769713481~hmac=7f81919b0d0fd4aa89143789d230ca4b7cefe80dec7db867e5041737049dc8ab&_a=BAMAK+TI0",
"__typename": "PictureUrls"
},
"pictureType": "SECONDARY",
"__typename": "Picture"
},
{
"id": "picture|profile#d254700f-ee2d-4765-9218-85dd61069bd5-effd3d2e-d1c0-4b6e-bac1-b374ae3c246b",
"pictureUrls": {
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/1c61cbc0-4805-45be-9ff8-b2668a103f0f?__cld_token__=exp=1769713481~hmac=7f8aeda0ac3feeb8f46b1d0fc9f6fb445808797102c0b474bb40deb95020a563&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/1c61cbc0-4805-45be-9ff8-b2668a103f0f?__cld_token__=exp=1769713481~hmac=49b4ac53d5b9b43d2bf0aed3ae6862065de891f0e5b24f03c2b48b01de816e11&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/1c61cbc0-4805-45be-9ff8-b2668a103f0f?__cld_token__=exp=1769713481~hmac=a40dcf53369bb88bec3986832e3c158cacaf736977fe8b93fa39621a19ee8db6&_a=BAMAK+TI0",
"__typename": "PictureUrls"
},
"pictureType": "SECONDARY",
"__typename": "Picture"
},
{
"id": "picture|profile#d254700f-ee2d-4765-9218-85dd61069bd5-e5b3a21e-dc81-4fe2-ad98-f3ff496758c2",
"pictureUrls": {
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/a1fe2ec6-65a5-4db4-8f9f-b6a703b8c7cd?__cld_token__=exp=1769713481~hmac=fd46637673520b477166a3fae8e35116bf8cf57338aa99a4bd3d658af7e04480&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/a1fe2ec6-65a5-4db4-8f9f-b6a703b8c7cd?__cld_token__=exp=1769713481~hmac=00390844b570c109f9d2c8b9f82cadf38a9231d07a8afd3b74debf0ebeb7e0d1&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/a1fe2ec6-65a5-4db4-8f9f-b6a703b8c7cd?__cld_token__=exp=1769713481~hmac=cd84de21b263296c509ac6016e652f552bc61d70d54d14ca7f99e37df2858793&_a=BAMAK+TI0",
"__typename": "PictureUrls"
},
"pictureType": "SECONDARY",
"__typename": "Picture"
},
{
"id": "picture|profile#d254700f-ee2d-4765-9218-85dd61069bd5-ab7c973c-cf4c-48b1-b568-5ff636825c7e",
"pictureUrls": {
"small": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_192,h_192,c_lfill,g_auto:faces/bd476531-e988-46d1-aad8-6c3216532b92?__cld_token__=exp=1769713481~hmac=fec95671b7d9907d0439ee95237af5e3f1c69431750339473edf34b70adc01b8&_a=BAMAK+TI0",
"medium": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_480,h_480,c_lfill,g_auto:faces/bd476531-e988-46d1-aad8-6c3216532b92?__cld_token__=exp=1769713481~hmac=16a9c9b85a72dec3026ee00ae6394a107669720f00e8eb04ad2dc7634f6ac281&_a=BAMAK+TI0",
"large": "https://res.cloudinary.com/threender/image/upload/q_auto:eco,f_webp,w_960,h_960,c_lfill,g_auto:faces/bd476531-e988-46d1-aad8-6c3216532b92?__cld_token__=exp=1769713481~hmac=e752245ab06b4589da3773204d2f81967da5c91f36117f2c02192f24750d508f&_a=BAMAK+TI0",
"__typename": "PictureUrls"
},
"pictureType": "SECONDARY",
"__typename": "Picture"
}
],
"dislikedAt": "2026-01-28T21:34:45.680Z"
},
{
"id": "profile#test123",
"imaginaryName": "Test User",
"age": 25,
"dislikedAt": "2026-01-28T21:34:06.646Z"
}
],
"updatedAt": "2026-01-28T22:29:10.956Z"
}

12
web/data/sentPings.json Executable file
View File

@@ -0,0 +1,12 @@
{
"pings": [
{
"targetProfileId": "profile#c793b2cb-62d5-4bf1-a66e-241a2d05d4d8",
"targetName": null,
"message": "I was going to say something clever, but then I saw that smile and your dog and lost my train of thought. Drinks/coffee when the sun comes back?",
"sentAt": 1769615620282,
"status": "SENT"
}
],
"updatedAt": "2026-01-28T15:53:40.000Z"
}

189
web/data/user.json Executable file
View File

@@ -0,0 +1,189 @@
{
"profileId": "profile#73b79341-5ff8-411c-806f-5b25cb834471",
"refreshToken": "AMf-vBwVzM1238ZitedXjt0WRzSfkdUEsCn7tM15FPtx5gKstJ9peh9BuydhK-r3csqjkrcNR0O-c4qR4cOKix03nZqcATJwpjUM5vEN2QIJHg_Z_DXpDcfVj5WstQSjumBpI--6qplbtGPTxqO-xnYgbRjunk-GECkLSE0ZiwRyEXh-MvpuJb8mrernjv9HrawS11jGPLi-_G5mtmxysVZqAfmgRubx_FqageMRF80bU-FatOxQtSo1JFiW6yYb728y1EK5F2CZutdRVJOJCJFbM-niM6KJhL12TNjAHgBm0r10k-1oJvg",
"likedProfiles": [
{
"id": "profile#f5fe7ccd-4f45-4fb7-a6b5-d0b3b8cb5eb3",
"name": "New to Big D",
"likedAt": 1769637486627
},
{
"id": "profile#ba95b8b7-23a8-44f1-b250-66b545ca6624",
"name": "DogMama",
"likedAt": 1769272255706
},
{
"id": "profile#00eebc7b-7cc4-469c-bdc2-1c1712c06990",
"name": "Natty",
"likedAt": 1769268478353
},
{
"id": "profile#a209c60b-8c99-4cd5-aacb-f363053be4a5",
"name": "Aurora",
"likedAt": 1769268443130
}
],
"currentLocation": {
"latitude": 32.8295183,
"longitude": -96.9442177,
"name": "Irving"
},
"savedLocations": [
{
"id": "36247697-0e22-4598-b68c-4009a7e3281e",
"name": "Flower Mound",
"latitude": 33.0283854,
"longitude": -97.0867203
},
{
"id": "a63b7f09-56dd-4939-96a2-d7ed50964816",
"name": "Houston",
"latitude": 29.7589382,
"longitude": -95.3676974
},
{
"id": "cb52945c-12bb-4850-babf-38f0d6abc32c",
"name": "Lewisville",
"latitude": 33.046233,
"longitude": -96.994174
},
{
"id": "dc7204ec-397c-4823-ae60-77f7daf18e7b",
"name": "Frisco",
"latitude": 33.1505998,
"longitude": -96.8238183
},
{
"id": "076001f9-b758-4ca6-a6a5-44173019a088",
"name": "Oak Cliff",
"latitude": 32.7392973,
"longitude": -96.8111126
},
{
"id": "ed0124b2-8bda-43d6-ad6e-e918ada7757d",
"name": "San Antonio",
"latitude": 29.4246002,
"longitude": -98.4951405
},
{
"id": "4601ec13-7f5d-4c88-b12b-8f8b1d3cf3b3",
"name": "Richardson",
"latitude": 32.9481789,
"longitude": -96.7297206
},
{
"id": "86e8bff2-f5b7-4f4a-84d7-f819a7c07f92",
"name": "North Richland Hills",
"latitude": 32.8342952,
"longitude": -97.2289029
},
{
"id": "4f60bda9-9827-4da5-a0fa-dc6fa25634e0",
"name": "denton",
"latitude": 33.1838787,
"longitude": -97.1413417
},
{
"id": "e91a1da7-460e-4997-979d-c9cba7c4aa70",
"name": "Jersey village",
"latitude": 29.888578,
"longitude": -95.5699185
},
{
"id": "d4395d25-b9fb-4021-92f6-7f384679bd66",
"name": "Houston",
"latitude": 29.7589382,
"longitude": -95.3676974
},
{
"id": "dc989fa9-9831-4cb7-9e3a-6196a50d88e2",
"name": "Dallas",
"latitude": 32.7762719,
"longitude": -96.7968559
},
{
"id": "99179d75-19f6-4401-aade-04ac0cba16fb",
"name": "Plano",
"latitude": 33.0136764,
"longitude": -96.6925096
},
{
"id": "0222973d-130d-43a8-a801-c0f3e6baaa57",
"name": "Austin",
"latitude": 30.2711286,
"longitude": -97.7436995
},
{
"id": "7f6f7d90-02b5-4eb5-9fc4-4a1d068ad8a2",
"name": "Trophy Club",
"latitude": 32.9979014,
"longitude": -97.1836246
},
{
"id": "8d851ef6-1596-4e12-8c02-0f1409699a4a",
"name": "Southlake",
"latitude": 32.9412363,
"longitude": -97.1341783
},
{
"id": "293e20a5-80de-48ac-a78f-eaaec9015ad6",
"name": "Garland",
"latitude": 32.912624,
"longitude": -96.6388833
},
{
"id": "ba26a591-fb3d-4190-824d-48863a3b343a",
"name": "argyle",
"latitude": 33.110156,
"longitude": -97.1797576
},
{
"id": "1a6e09bb-64c6-4e37-935e-bbad8ee9e99a",
"name": "Carrollton",
"latitude": 32.9515472,
"longitude": -96.9034244
},
{
"id": "a567cbd0-8993-4d46-8bc8-457b68d4ff40",
"name": "grand prairie",
"latitude": 32.7459645,
"longitude": -96.9977846
},
{
"id": "dc75c080-a484-45ea-8439-026b40a4fc52",
"name": "Addison",
"latitude": 32.9601193,
"longitude": -96.8300029
},
{
"id": "ea1933e4-2663-4047-ade0-c0e345e0a882",
"name": "McKinney",
"latitude": 33.1976496,
"longitude": -96.6154471
},
{
"id": "e66b70a7-0761-43cb-a65a-104651878f2e",
"name": "Arlington",
"latitude": 32.7355816,
"longitude": -97.1071186
},
{
"id": "6e09d078-f4e0-49f0-8f68-582254fb79af",
"name": "irving",
"latitude": 32.8295183,
"longitude": -96.9442177
},
{
"id": "dbbf0806-ca2b-4d11-9186-bc0ee3090c85",
"name": "Las Colinas",
"latitude": 32.8906896,
"longitude": -96.9618794
}
],
"updatedAt": "2026-02-09T15:58:22.139Z",
"customLocation": {
"latitude": 32.8906896,
"longitude": -96.9618794,
"name": "Las Colinas"
}
}

1534
web/data/whoLikedYou.json Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
# Docker Compose for Local Development
#
# Access at http://localhost:7743
#
# Usage:
# docker-compose -f docker-compose.local.yml up -d --build # Start
# docker-compose -f docker-compose.local.yml down # Stop
# docker-compose -f docker-compose.local.yml logs -f # View logs
services:
backend:
build:
context: .
dockerfile: server/Dockerfile
container_name: feeld-web-backend
restart: unless-stopped
volumes:
- ./data:/data
environment:
- DATA_PATH=/data
- PORT=3001
networks:
- feeld-web
frontend:
build:
context: .
dockerfile: Dockerfile
container_name: feeld-web-frontend
restart: unless-stopped
volumes:
- .:/app
- /app/node_modules
environment:
- VITE_API_URL=
depends_on:
- backend
networks:
- feeld-web
nginx:
image: nginx:alpine
container_name: feeld-web-nginx
restart: unless-stopped
ports:
- "7743:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
- frontend
networks:
- feeld-web
networks:
feeld-web:
name: feeld-web-local

64
web/docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
# Docker Compose for Unraid - Feeld Web App
#
# Access at http://YOUR_UNRAID_IP:7743
#
# ============================================
# CONFIGURE THESE PATHS FOR YOUR UNRAID SETUP
# ============================================
# Edit the left side of the colon (:) for each volume mount
#
# APP_PATH: Where the app code lives on Unraid (/mnt/user/appdata/FeeldWeb)
# DATA_PATH: Where to store persistent user data (/mnt/user/downloads/feeldWeb)
services:
backend:
build:
context: /mnt/user/appdata/FeeldWeb
dockerfile: server/Dockerfile
container_name: feeld-web-backend
restart: unless-stopped
volumes:
# === CONFIGURABLE DATA PATH ===
- /mnt/user/downloads/feeldWeb:/data # DATA_PATH - persistent storage (separate from app)
environment:
- DATA_PATH=/data
- PORT=3001
networks:
- feeld-web
frontend:
build:
context: /mnt/user/appdata/FeeldWeb
dockerfile: Dockerfile
container_name: feeld-web-frontend
restart: unless-stopped
volumes:
- /mnt/user/appdata/FeeldWeb:/app
- feeld-web-node-modules:/app/node_modules
environment:
- VITE_API_URL=
depends_on:
- backend
networks:
- feeld-web
nginx:
image: nginx:alpine
container_name: feeld-web-nginx
restart: unless-stopped
ports:
- "7743:80"
volumes:
- /mnt/user/appdata/FeeldWeb/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
- frontend
networks:
- feeld-web
networks:
feeld-web:
name: feeld-web
volumes:
feeld-web-node-modules:

16
web/docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
set -e
echo "==> Checking node_modules..."
# If node_modules is empty/missing, copy from cache
if [ ! -d "node_modules/react" ]; then
echo "==> Initializing node_modules from cache..."
cp -r /node_modules_cache/* node_modules/ 2>/dev/null || cp -r /node_modules_cache/. node_modules/
echo "==> Done"
else
echo "==> node_modules already initialized"
fi
echo "==> Starting Feeld Web..."
exec "$@"

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

119
web/nginx.conf Executable file
View File

@@ -0,0 +1,119 @@
events {
worker_connections 1024;
}
http {
access_log /dev/stdout;
error_log /dev/stderr warn;
upstream frontend {
server feeld-web-frontend:3000;
}
upstream backend {
server feeld-web-backend:3001;
}
server {
listen 80;
server_name _;
# Data API requests go to our Express backend
# (liked profiles, user data persistence)
location /api/data/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Health check for our backend
location /api/health {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# Auth endpoints
location /api/auth/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Who liked you endpoint
location /api/who-liked-you {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Sent pings endpoint
location /api/sent-pings {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Disliked profiles endpoint
location /api/disliked-profiles {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Location rotation endpoints
location /api/location-rotation {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Saved locations endpoint
location /api/saved-locations {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Discovered profiles cache endpoint
location /api/discovered-profiles {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Everything else (including /api/graphql, /api/firebase, etc.)
# goes to Vite which handles its own proxying
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
}

7318
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
web/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "node server/index.js",
"dev:all": "node server/index.js & vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"docker:local": "docker compose -f docker-compose.local.yml up -d --build",
"docker:local:down": "docker compose -f docker-compose.local.yml down",
"docker:local:logs": "docker compose -f docker-compose.local.yml logs -f"
},
"dependencies": {
"@apollo/client": "^4.1.2",
"@tailwindcss/vite": "^4.1.18",
"cors": "^2.8.6",
"express": "^5.2.1",
"graphql": "^16.12.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"stream-chat": "^9.29.0",
"stream-chat-react": "^13.13.4"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

22
web/server/Dockerfile Executable file
View File

@@ -0,0 +1,22 @@
# Backend Dockerfile - Express Data Server
FROM node:20-alpine
WORKDIR /app
# Copy package files from parent (uses express from main package.json)
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production
# Copy server code
COPY server/ ./server/
# Create data directory
RUN mkdir -p /data
# Expose Express server port
EXPOSE 3001
# Run Express server
CMD ["node", "server/index.js"]

1040
web/server/index.js Executable file

File diff suppressed because it is too large Load Diff

197
web/src/App.tsx Executable file
View File

@@ -0,0 +1,197 @@
import { ApolloProvider } from '@apollo/client/react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { apolloClient } from './api/client';
import { Layout } from './components/layout/Layout';
import { LocationProvider } from './hooks/useLocation';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { StreamChatProvider } from './context/StreamChatContext';
import { LoginPage } from './components/LoginPage';
import { DiscoverPage } from './pages/Discover';
import { LikesPage } from './pages/Likes';
import { MessagesPage } from './pages/Messages';
import { ChatPage } from './pages/Chat';
import { ProfilePage } from './pages/Profile';
import { SettingsPage } from './pages/Settings';
import { SentPingsPage } from './pages/SentPings';
import { ApiExplorerPage } from './pages/ApiExplorer';
import { useEffect, useState, useRef } from 'react';
import { initialSync } from './api/dataSync';
import { authManager } from './api/auth';
// Prevent browser tab discarding by keeping minimal activity
function usePreventTabDiscard() {
const intervalRef = useRef<number | null>(null);
useEffect(() => {
// Ping every 30 seconds to prevent browser from discarding tab
intervalRef.current = window.setInterval(() => {
// Minimal operation to keep tab alive
localStorage.setItem('_keepalive', Date.now().toString());
}, 30000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
}
function AuthenticatedApp() {
const [tokenReady, setTokenReady] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
// Ensure Firebase token is ready before rendering Apollo content
useEffect(() => {
authManager.ensureReady()
.then((ready) => {
if (ready) {
setTokenReady(true);
// Sync data after token is ready
initialSync().catch(console.error);
} else {
setTokenError('Failed to initialize authentication');
}
})
.catch((err) => {
setTokenError(err.message || 'Authentication failed');
});
}, []);
if (tokenError) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: '#0f0f14',
color: '#ef4444',
gap: '1rem',
padding: '20px',
}}>
<div style={{ fontSize: '1.25rem', fontWeight: 600 }}>Authentication Error</div>
<div style={{ color: '#6b7280', fontSize: '0.875rem', textAlign: 'center', maxWidth: '400px' }}>{tokenError}</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
<button
onClick={() => {
setTokenError(null);
setTokenReady(false);
authManager.forceRefresh()
.then(() => setTokenReady(true))
.catch(err => setTokenError(err.message || 'Retry failed'));
}}
style={{
padding: '14px 28px',
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '12px',
color: '#ffffff',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Retry
</button>
<button
onClick={() => {
localStorage.clear();
window.location.reload();
}}
style={{
padding: '14px 28px',
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '12px',
color: '#ef4444',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Logout &amp; Start Over
</button>
</div>
</div>
);
}
if (!tokenReady) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0f0f14',
color: '#6b7280',
}}>
Connecting...
</div>
);
}
return (
<ApolloProvider client={apolloClient}>
<StreamChatProvider>
<LocationProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/discover" replace />} />
<Route path="discover" element={<DiscoverPage />} />
<Route path="likes" element={<LikesPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="chat/:channelId" element={<ChatPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="sent-pings" element={<SentPingsPage />} />
<Route path="api-explorer" element={<ApiExplorerPage />} />
</Route>
</Routes>
</BrowserRouter>
</LocationProvider>
</StreamChatProvider>
</ApolloProvider>
);
}
function AppContent() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0f0f14',
color: '#6b7280',
}}>
Loading...
</div>
);
}
if (!isAuthenticated) {
return <LoginPage />;
}
return <AuthenticatedApp />;
}
function App() {
// Prevent browser from discarding this tab when inactive
usePreventTabDiscard();
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}
export default App;

151
web/src/api/auth.ts Executable file
View File

@@ -0,0 +1,151 @@
import { API_CONFIG, getCredentials } from '../config/constants';
interface TokenResponse {
access_token: string;
expires_in: string;
token_type: string;
refresh_token: string;
id_token: string;
user_id: string;
project_id: string;
}
export interface AuthStatus {
isAuthenticated: boolean;
expiresAt: number | null;
expiresIn: number | null; // seconds until expiry
accessToken: string | null; // full token
lastError: string | null;
}
class AuthManager {
private accessToken: string | null = null;
private expiresAt: number = 0;
private lastError: string | null = null;
private listeners: Set<() => void> = new Set();
private initPromise: Promise<void> | null = null;
private isReady: boolean = false;
// Ensure token is ready before any queries
async ensureReady(): Promise<boolean> {
if (this.isReady && this.accessToken) {
return true;
}
if (!this.initPromise) {
this.initPromise = this.refresh()
.then(() => {
this.isReady = true;
})
.catch((err) => {
console.error('Initial token fetch failed:', err);
this.isReady = false;
});
}
await this.initPromise;
return this.isReady;
}
async getToken(): Promise<string> {
// Refresh if token expires in less than 1 minute
if (!this.accessToken || Date.now() >= this.expiresAt - 60000) {
await this.refresh();
}
return this.accessToken!;
}
private async refresh(): Promise<void> {
const creds = getCredentials();
const url = `${API_CONFIG.FIREBASE_TOKEN_URL}?key=${API_CONFIG.FIREBASE_API_KEY}`;
console.log('Refreshing token at:', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grantType: 'refresh_token',
refreshToken: creds.REFRESH_TOKEN,
}),
});
if (!response.ok) {
const error = await response.text();
console.error('Token refresh failed:', response.status, error);
this.lastError = `HTTP ${response.status}: ${error}`;
this.notifyListeners();
throw new Error(`Failed to refresh token: ${error}`);
}
const data: TokenResponse = await response.json();
this.accessToken = data.access_token;
this.expiresAt = Date.now() + parseInt(data.expires_in) * 1000;
this.lastError = null;
console.log('Token refreshed, expires in:', data.expires_in, 'seconds');
// Seed the backend with updated refresh token for rotation cron
try {
const creds = getCredentials();
fetch('/api/location-rotation/seed-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refreshToken: creds.REFRESH_TOKEN,
profileId: creds.PROFILE_ID,
analyticsId: creds.EVENT_ANALYTICS_ID,
}),
}).catch(() => {}); // Best-effort, don't block auth flow
} catch (e) {}
this.notifyListeners();
} catch (err) {
this.lastError = err instanceof Error ? err.message : 'Unknown error';
this.notifyListeners();
throw err;
}
}
// Force a token refresh (useful after updating credentials)
async forceRefresh(): Promise<void> {
this.accessToken = null;
this.expiresAt = 0;
await this.refresh();
}
getProfileId(): string {
return getCredentials().PROFILE_ID;
}
isAuthenticated(): boolean {
return this.accessToken !== null && Date.now() < this.expiresAt;
}
getStatus(): AuthStatus {
const now = Date.now();
const expiresIn = this.expiresAt > 0 ? Math.floor((this.expiresAt - now) / 1000) : null;
return {
isAuthenticated: this.isAuthenticated(),
expiresAt: this.expiresAt > 0 ? this.expiresAt : null,
expiresIn: expiresIn && expiresIn > 0 ? expiresIn : null,
accessToken: this.accessToken,
lastError: this.lastError,
};
}
// Subscribe to auth status changes
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners() {
this.listeners.forEach(listener => listener());
}
}
export const authManager = new AuthManager();

82
web/src/api/client.ts Executable file
View File

@@ -0,0 +1,82 @@
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client/core';
import { setContext } from '@apollo/client/link/context';
import { API_CONFIG, REQUEST_HEADERS, TEST_CREDENTIALS } from '../config/constants';
import { authManager } from './auth';
// UUID generator that works in non-secure contexts (HTTP)
function generateUUID(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for non-secure contexts
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
const httpLink = createHttpLink({
uri: API_CONFIG.GRAPHQL_ENDPOINT,
});
// Auth link that adds exact headers from Proxyman capture
const authLink = setContext(async (operation, { headers }) => {
console.log('GraphQL operation:', operation.operationName);
let token: string;
try {
token = await authManager.getToken();
console.log('Got auth token, length:', token?.length);
} catch (err) {
console.error('Failed to get auth token:', err);
throw err;
}
// Exact headers from Proxyman capture
const newHeaders = {
...headers,
// From Proxyman: Host header (handled by browser)
'Accept': REQUEST_HEADERS['Accept'],
'Accept-Language': REQUEST_HEADERS['Accept-Language'],
'Accept-Encoding': REQUEST_HEADERS['Accept-Encoding'],
'Content-Type': REQUEST_HEADERS['Content-Type'],
'Connection': REQUEST_HEADERS['Connection'],
'User-Agent': REQUEST_HEADERS['User-Agent'],
// Auth headers
'Authorization': `Bearer ${token}`,
'x-profile-id': TEST_CREDENTIALS.PROFILE_ID,
// App identification headers - exact from Proxyman
'x-device-os': REQUEST_HEADERS['x-device-os'],
'x-app-version': REQUEST_HEADERS['x-app-version'],
'x-os-version': REQUEST_HEADERS['x-os-version'],
// Transaction headers
'x-transaction-id': generateUUID(),
'x-event-analytics-id': TEST_CREDENTIALS.EVENT_ANALYTICS_ID,
};
console.log('Request headers:', Object.keys(newHeaders));
return { headers: newHeaders };
});
export const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Profile: {
keyFields: ['id'],
},
Chat: {
keyFields: ['id'],
},
ChatSummary: {
keyFields: ['id'],
},
},
}),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});

561
web/src/api/dataSync.ts Executable file
View File

@@ -0,0 +1,561 @@
// Data sync service - syncs with server, falls back to localStorage
// In Docker, nginx proxies /api to backend. Locally, use localhost:3001
const SERVER_URL = import.meta.env.VITE_API_URL ||
(window.location.port === '5173' ? 'http://localhost:3001/api' : '/api');
const LOCAL_STORAGE_PREFIX = 'feeld_';
// Use a consistent user ID for single-user mode
// This ensures all data goes to the same file regardless of profile ID changes
const getUserId = (): string => {
return 'user';
};
// Check if server is available
let serverAvailable: boolean | null = null;
let serverCheckPromise: Promise<boolean> | null = null;
const checkServerAvailable = async (): Promise<boolean> => {
if (serverAvailable !== null) return serverAvailable;
if (!serverCheckPromise) {
serverCheckPromise = fetch(`${SERVER_URL}/health`, { method: 'GET' })
.then(res => {
serverAvailable = res.ok;
return serverAvailable;
})
.catch(() => {
serverAvailable = false;
return false;
})
.finally(() => {
// Recheck every 30 seconds
setTimeout(() => {
serverAvailable = null;
serverCheckPromise = null;
}, 30000);
});
}
return serverCheckPromise;
};
// Sync a specific key to the server
export const syncToServer = async (key: string, value: any): Promise<boolean> => {
const isAvailable = await checkServerAvailable();
if (!isAvailable) return false;
try {
const response = await fetch(`${SERVER_URL}/data/${getUserId()}/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
return response.ok;
} catch (e) {
console.error('Failed to sync to server:', e);
return false;
}
};
// Get data from server
export const getFromServer = async (key: string): Promise<any | null> => {
const isAvailable = await checkServerAvailable();
if (!isAvailable) return null;
try {
const response = await fetch(`${SERVER_URL}/data/${getUserId()}/${key}`);
if (response.ok) {
const data = await response.json();
return data[key];
}
return null;
} catch (e) {
console.error('Failed to get from server:', e);
return null;
}
};
// Get all data from server
export const getAllFromServer = async (): Promise<any | null> => {
const isAvailable = await checkServerAvailable();
if (!isAvailable) return null;
try {
const response = await fetch(`${SERVER_URL}/data/${getUserId()}`);
if (response.ok) {
return await response.json();
}
return null;
} catch (e) {
console.error('Failed to get all from server:', e);
return null;
}
};
// Combined get: try server first, fall back to localStorage
export const getData = async <T>(key: string, defaultValue: T): Promise<T> => {
// Try server first
const serverData = await getFromServer(key);
if (serverData !== null) {
// Update localStorage with server data
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${key}`, JSON.stringify(serverData));
return serverData as T;
}
// Fall back to localStorage
const localData = localStorage.getItem(`${LOCAL_STORAGE_PREFIX}${key}`);
if (localData) {
try {
return JSON.parse(localData) as T;
} catch (e) {
return defaultValue;
}
}
return defaultValue;
};
// Combined set: save to both localStorage and server
export const setData = async (key: string, value: any): Promise<void> => {
// Always save to localStorage first (immediate)
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${key}`, JSON.stringify(value));
// Then sync to server (async)
syncToServer(key, value);
};
// Liked profiles specific functions
export const addLikedProfile = async (id: string, name?: string): Promise<void> => {
const isAvailable = await checkServerAvailable();
// Get current liked profiles
const current = await getData<Array<{ id: string; name?: string; likedAt: number }>>('liked_profiles', []);
// Don't add duplicates
if (current.some(p => p.id === id)) return;
const newProfile = { id, name, likedAt: Date.now() };
const updated = [newProfile, ...current];
// Save to localStorage
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}liked_profiles`, JSON.stringify(updated));
// Sync to server
if (isAvailable) {
try {
await fetch(`${SERVER_URL}/data/${getUserId()}/liked-profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name }),
});
} catch (e) {
console.error('Failed to sync liked profile to server:', e);
}
}
};
export const removeLikedProfile = async (id: string): Promise<void> => {
const isAvailable = await checkServerAvailable();
// Get current and filter
const current = await getData<Array<{ id: string; name?: string; likedAt: number }>>('liked_profiles', []);
const updated = current.filter(p => p.id !== id);
// Save to localStorage
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}liked_profiles`, JSON.stringify(updated));
// Sync to server
if (isAvailable) {
try {
await fetch(`${SERVER_URL}/data/${getUserId()}/liked-profiles/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
} catch (e) {
console.error('Failed to remove liked profile from server:', e);
}
}
};
export const getLikedProfiles = async (): Promise<Array<{ id: string; name?: string; likedAt: number }>> => {
return getData('liked_profiles', []);
};
// Initial sync: MERGE data from server and localStorage (keep richer data from both)
export const initialSync = async (): Promise<void> => {
const serverData = await getAllFromServer();
// Helper to merge arrays by ID, keeping all unique items
const mergeArraysById = (serverArr: any[] | undefined, localArr: any[], idField: string = 'id'): any[] => {
const merged = [...(serverArr || [])];
for (const local of localArr) {
if (!merged.some(s => s[idField] === local[idField])) {
merged.push(local);
}
}
return merged;
};
// Merge liked profiles (keep all from both sources)
const localLiked = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}liked_profiles`) || '[]');
const mergedLiked = mergeArraysById(serverData?.likedProfiles, localLiked, 'id');
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}liked_profiles`, JSON.stringify(mergedLiked));
// Sync merged result back to server if we added local items
if (mergedLiked.length > (serverData?.likedProfiles?.length || 0)) {
await setData('likedProfiles', mergedLiked);
}
// Merge saved locations
const localLocations = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}saved_locations`) || '[]');
const mergedLocations = mergeArraysById(serverData?.savedLocations, localLocations, 'name');
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}saved_locations`, JSON.stringify(mergedLocations));
localStorage.setItem(`feeld_locations`, JSON.stringify(mergedLocations));
if (mergedLocations.length > (serverData?.savedLocations?.length || 0)) {
await setData('savedLocations', mergedLocations);
}
// Credentials: prefer server if available (more authoritative)
if (serverData?.profileId) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}profile_id`, serverData.profileId);
}
if (serverData?.refreshToken) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}refresh_token`, serverData.refreshToken);
}
if (serverData?.analyticsId) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}analytics_id`, serverData.analyticsId);
}
// Current location: prefer server if local is empty
if (serverData?.currentLocation) {
const localCurrent = localStorage.getItem('feeld_current_location');
if (!localCurrent) {
localStorage.setItem('feeld_current_location', JSON.stringify(serverData.currentLocation));
}
}
// Custom location: prefer server if local is empty
if (serverData?.customLocation) {
const localCustom = localStorage.getItem(`${LOCAL_STORAGE_PREFIX}custom_location`);
if (!localCustom) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}custom_location`, JSON.stringify(serverData.customLocation));
}
}
console.log('Synced and merged data from server', {
likedProfiles: mergedLiked.length,
savedLocations: mergedLocations.length,
});
};
// Save credentials to server - merges with existing server data to prevent data loss
export const saveCredentials = async (profileId: string, refreshToken: string, analyticsId?: string): Promise<void> => {
const isAvailable = await checkServerAvailable();
// Save to localStorage
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}profile_id`, profileId);
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}refresh_token`, refreshToken);
if (analyticsId) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}analytics_id`, analyticsId);
}
// Sync to server - but first pull server data to merge
if (isAvailable) {
try {
// Get existing server data first
const serverData = await getAllFromServer();
// Only update credentials, preserve everything else on server
await setData('profileId', profileId);
await setData('refreshToken', refreshToken);
if (analyticsId) {
await setData('analyticsId', analyticsId);
}
await setData('updatedAt', new Date().toISOString());
// If server had data that localStorage doesn't, restore it to localStorage
if (serverData) {
if (serverData.likedProfiles && serverData.likedProfiles.length > 0) {
const localLiked = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}liked_profiles`) || '[]');
// Merge: keep all from server, add any from local that aren't duplicates
const mergedLiked = [...serverData.likedProfiles];
for (const local of localLiked) {
if (!mergedLiked.some(s => s.id === local.id)) {
mergedLiked.push(local);
}
}
if (mergedLiked.length > localLiked.length) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}liked_profiles`, JSON.stringify(mergedLiked));
console.log(`Restored ${mergedLiked.length - localLiked.length} liked profiles from server`);
}
}
if (serverData.savedLocations && serverData.savedLocations.length > 0) {
const localLocations = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}saved_locations`) || '[]');
if (serverData.savedLocations.length > localLocations.length) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}saved_locations`, JSON.stringify(serverData.savedLocations));
console.log(`Restored ${serverData.savedLocations.length} saved locations from server`);
}
}
if (serverData.currentLocation && !localStorage.getItem('feeld_current_location')) {
localStorage.setItem('feeld_current_location', JSON.stringify(serverData.currentLocation));
console.log('Restored current location from server');
}
if (serverData.customLocation && !localStorage.getItem(`${LOCAL_STORAGE_PREFIX}custom_location`)) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}custom_location`, JSON.stringify(serverData.customLocation));
console.log('Restored custom location from server');
}
}
} catch (e) {
console.error('Failed to sync credentials to server:', e);
}
}
};
// Save custom location to server
export const saveCustomLocation = async (location: { latitude: number; longitude: number; name?: string }): Promise<void> => {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}custom_location`, JSON.stringify(location));
await setData('customLocation', location);
};
// Sent pings specific functions - uses dedicated /sent-pings endpoint
export interface SentPing {
targetProfileId: string;
targetName?: string;
message?: string;
sentAt: number;
status: 'SENT' | 'MATCHED' | 'EXPIRED';
}
export const addSentPing = async (targetProfileId: string, targetName?: string, message?: string): Promise<void> => {
const isAvailable = await checkServerAvailable();
const newPing: SentPing = {
targetProfileId,
targetName,
message,
sentAt: Date.now(),
status: 'SENT',
};
// Save to localStorage as backup
const current = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}sent_pings`) || '[]');
if (!current.some((p: SentPing) => p.targetProfileId === targetProfileId)) {
const updated = [newPing, ...current];
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}sent_pings`, JSON.stringify(updated));
}
// Sync to dedicated sent pings endpoint
if (isAvailable) {
try {
await fetch(`${SERVER_URL}/sent-pings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetProfileId, targetName, message }),
});
} catch (e) {
console.error('Failed to sync sent ping to server:', e);
}
}
};
export const updateSentPingStatus = async (targetProfileId: string, status: 'SENT' | 'MATCHED' | 'EXPIRED'): Promise<void> => {
// Update localStorage
const current = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}sent_pings`) || '[]');
const updated = current.map((p: SentPing) =>
p.targetProfileId === targetProfileId ? { ...p, status } : p
);
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}sent_pings`, JSON.stringify(updated));
// Update server
const isAvailable = await checkServerAvailable();
if (isAvailable) {
try {
await fetch(`${SERVER_URL}/sent-pings/${encodeURIComponent(targetProfileId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
} catch (e) {
console.error('Failed to update sent ping status on server:', e);
}
}
};
export const removeSentPing = async (targetProfileId: string): Promise<void> => {
// Update localStorage
const current = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}sent_pings`) || '[]');
const updated = current.filter((p: SentPing) => p.targetProfileId !== targetProfileId);
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}sent_pings`, JSON.stringify(updated));
// Delete from server
const isAvailable = await checkServerAvailable();
if (isAvailable) {
try {
await fetch(`${SERVER_URL}/sent-pings/${encodeURIComponent(targetProfileId)}`, {
method: 'DELETE',
});
} catch (e) {
console.error('Failed to remove sent ping from server:', e);
}
}
};
export const getSentPings = async (): Promise<SentPing[]> => {
const isAvailable = await checkServerAvailable();
// Try server first
if (isAvailable) {
try {
const response = await fetch(`${SERVER_URL}/sent-pings`);
if (response.ok) {
const data = await response.json();
const pings = data.pings || [];
// Update localStorage with server data
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}sent_pings`, JSON.stringify(pings));
return pings;
}
} catch (e) {
console.error('Failed to fetch sent pings from server:', e);
}
}
// Fallback to localStorage
return JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}sent_pings`) || '[]');
};
export const clearAllSentPings = async (): Promise<void> => {
// Clear localStorage
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}sent_pings`, '[]');
// Clear server by getting all pings and deleting them
const isAvailable = await checkServerAvailable();
if (isAvailable) {
try {
const response = await fetch(`${SERVER_URL}/sent-pings`);
if (response.ok) {
const data = await response.json();
const pings = data.pings || [];
// Delete each ping
for (const ping of pings) {
await fetch(`${SERVER_URL}/sent-pings/${encodeURIComponent(ping.targetProfileId)}`, {
method: 'DELETE',
});
}
}
} catch (e) {
console.error('Failed to clear sent pings from server:', e);
}
}
};
// Disliked profiles specific functions - uses dedicated /disliked-profiles endpoint
export interface DislikedProfile {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
photos?: any[];
dislikedAt: string;
}
export const addDislikedProfile = async (profile: Omit<DislikedProfile, 'dislikedAt'>): Promise<void> => {
const isAvailable = await checkServerAvailable();
const newProfile: DislikedProfile = {
...profile,
dislikedAt: new Date().toISOString(),
};
// Save to localStorage as backup
const current = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}disliked_profiles`) || '[]');
if (!current.some((p: DislikedProfile) => p.id === profile.id)) {
const updated = [newProfile, ...current];
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}disliked_profiles`, JSON.stringify(updated));
}
// Sync to dedicated disliked profiles endpoint
if (isAvailable) {
try {
await fetch(`${SERVER_URL}/disliked-profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile }),
});
} catch (e) {
console.error('Failed to sync disliked profile to server:', e);
}
}
};
export const removeDislikedProfile = async (profileId: string): Promise<void> => {
// Update localStorage
const current = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}disliked_profiles`) || '[]');
const updated = current.filter((p: DislikedProfile) => p.id !== profileId);
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}disliked_profiles`, JSON.stringify(updated));
// Delete from server
const isAvailable = await checkServerAvailable();
if (isAvailable) {
try {
await fetch(`${SERVER_URL}/disliked-profiles/${encodeURIComponent(profileId)}`, {
method: 'DELETE',
});
} catch (e) {
console.error('Failed to remove disliked profile from server:', e);
}
}
};
export const getDislikedProfiles = async (): Promise<DislikedProfile[]> => {
const isAvailable = await checkServerAvailable();
// Try server first
if (isAvailable) {
try {
const response = await fetch(`${SERVER_URL}/disliked-profiles`);
if (response.ok) {
const data = await response.json();
const profiles = data.profiles || [];
// Update localStorage with server data
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}disliked_profiles`, JSON.stringify(profiles));
return profiles;
}
} catch (e) {
console.error('Failed to fetch disliked profiles from server:', e);
}
}
// Fallback to localStorage
return JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}disliked_profiles`) || '[]');
};
export const clearAllDislikedProfiles = async (): Promise<void> => {
// Clear localStorage
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}disliked_profiles`, '[]');
// Clear server by getting all profiles and deleting them
const isAvailable = await checkServerAvailable();
if (isAvailable) {
try {
const response = await fetch(`${SERVER_URL}/disliked-profiles`);
if (response.ok) {
const data = await response.json();
const profiles = data.profiles || [];
// Delete each profile
for (const profile of profiles) {
await fetch(`${SERVER_URL}/disliked-profiles/${encodeURIComponent(profile.id)}`, {
method: 'DELETE',
});
}
}
} catch (e) {
console.error('Failed to clear disliked profiles from server:', e);
}
}
};
// Export server status check
export const isServerAvailable = checkServerAvailable;

View File

@@ -0,0 +1,300 @@
import { gql } from '@apollo/client/core';
// Experimental queries to discover hidden API endpoints
// Based on patterns: whoLikesMe, whoPingsMe -> try whoILiked, whoIPinged, myLikes, etc.
export const LIKES_PROFILE_FRAGMENT = gql`
fragment LikesProfileFragment on Profile {
id
age
gender
status
lastSeen
desires
connectionGoals
isUplift
sexuality
isMajestic
dateOfBirth
streamUserId
imaginaryName
bio
hiddenBio
hasHiddenBio
allowPWM
interests
verificationStatus
interactionStatus {
message
mine
theirs
__typename
}
distance {
km
mi
__typename
}
photos {
id
pictureIsPrivate
pictureIsSafe
pictureStatus
pictureType
pictureUrl
pictureUrls {
small
medium
large
__typename
}
publicId
__typename
}
__typename
}
`;
// Attempt 1: whoILiked (mirror of whoLikesMe)
export const WHO_I_LIKED_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query WhoILiked($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: whoILiked(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 2: myLikes
export const MY_LIKES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query MyLikes($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: myLikes(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 3: sentLikes
export const SENT_LIKES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query SentLikes($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: sentLikes(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 4: likedProfiles
export const LIKED_PROFILES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query LikedProfiles($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: likedProfiles(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 5: profilesILiked
export const PROFILES_I_LIKED_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query ProfilesILiked($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: profilesILiked(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 6: outgoingLikes (opposite of incoming likes)
export const OUTGOING_LIKES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query OutgoingLikes($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: outgoingLikes(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 7: Try interactions query with direction parameter
export const INTERACTIONS_OUTGOING_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query InteractionsOutgoing($limit: Int, $cursor: String, $sortBy: SortBy!, $direction: String) {
interactions(
input: {sortBy: $sortBy, direction: $direction}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 8: Try whoLikesMe with a filter for mine=LIKED
export const FILTERED_WHO_I_LIKED_MUTATION = gql`
${LIKES_PROFILE_FRAGMENT}
mutation FilteredWhoILiked($input: FilteredInteractionInput!, $cursor: String) {
filteredWhoILiked(input: $input, cursor: $cursor) {
filters {
ageRange
desires
lookingFor
sexualities
__typename
}
profiles {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
unfilteredTotal
hasNextPage
nextPageCursor
__typename
}
__typename
}
__typename
}
}
`;
// Direct profile lookup query - to test if profile IDs from WhoLikesMe return real data
export const DIRECT_PROFILE_LOOKUP_QUERY = gql`
query DirectProfileLookup($profileId: String!) {
profile(id: $profileId) {
id
age
gender
sexuality
imaginaryName
bio
desires
connectionGoals
verificationStatus
distance {
km
mi
__typename
}
photos {
id
pictureUrl
pictureUrls {
small
medium
large
__typename
}
publicId
__typename
}
__typename
}
}
`;
// List of all experimental queries to try
export const EXPERIMENTAL_QUERIES = [
{ name: 'whoILiked', query: WHO_I_LIKED_QUERY },
{ name: 'myLikes', query: MY_LIKES_QUERY },
{ name: 'sentLikes', query: SENT_LIKES_QUERY },
{ name: 'likedProfiles', query: LIKED_PROFILES_QUERY },
{ name: 'profilesILiked', query: PROFILES_I_LIKED_QUERY },
{ name: 'outgoingLikes', query: OUTGOING_LIKES_QUERY },
];

View File

@@ -0,0 +1,296 @@
import { gql } from '@apollo/client/core';
// Mutations - exact from Proxyman
export const PROFILE_LIKE_MUTATION = gql`
mutation ProfileLike($targetProfileId: String!) {
profileLike(input: { targetProfileId: $targetProfileId }) {
status
chat {
id
name
type
streamChatId
status
members {
id
status
analyticsId
imaginaryName
streamUserId
age
dateOfBirth
sexuality
isIncognito
gender
photos {
id
publicId
pictureIsSafe
pictureIsPrivate
pictureUrl
pictureUrls {
small
medium
large
__typename
}
pictureType
__typename
}
__typename
}
__typename
}
__typename
}
}
`;
export const DEVICE_LOCATION_UPDATE_MUTATION = gql`
mutation DeviceLocationUpdate($input: DeviceLocationInput!) {
deviceLocationUpdate(input: $input) {
id
location {
device {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
profiles {
id
location {
... on DeviceLocation {
device {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
... on TeleportLocation {
current: device {
city
country
__typename
}
teleport {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
__typename
}
__typename
}
__typename
}
}
`;
export const SEARCH_SETTINGS_UPDATE_MUTATION = gql`
mutation SearchSettingsUpdate(
$ageRange: [Int]
$distanceMax: Float
$desiringFor: [Desire!]
$lookingFor: [LookingFor!]
$recentlyOnline: Boolean
) {
profileUpdate(
input: {
ageRange: $ageRange
distanceMax: $distanceMax
desiringFor: $desiringFor
lookingFor: $lookingFor
recentlyOnline: $recentlyOnline
}
) {
id
ageRange
distanceMax
desiringFor
lookingFor
location {
... on DeviceLocation {
device {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
... on VirtualLocation {
core
__typename
}
... on TeleportLocation {
current: device {
city
country
__typename
}
teleport {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
__typename
}
recentlyOnline
__typename
}
}
`;
export const LAST_SEEN_UPDATE_MUTATION = gql`
mutation LastSeenProviderUpdateProfile($profileId: String!) {
lastSeenProviderUpdateProfile(profileId: $profileId) {
id
lastSeen
__typename
}
}
`;
export const ACCOUNT_ADD_NOTIFICATION_TOKEN_MUTATION = gql`
mutation AccountAddNotificationToken($token: String!) {
accountAddNotificationToken(input: { token: $token })
}
`;
export const PROFILE_PING_MUTATION = gql`
mutation ProfilePing($targetProfileId: String!, $message: String, $overrideInappropriate: Boolean) {
profilePing(
input: {targetProfileId: $targetProfileId, message: $message, overrideInappropriate: $overrideInappropriate}
) {
status
chat {
id
name
type
streamChatId
status
members {
id
status
analyticsId
imaginaryName
streamUserId
age
dateOfBirth
sexuality
isIncognito
gender
photos {
id
publicId
pictureIsSafe
pictureIsPrivate
pictureUrl
pictureUrls {
small
medium
large
__typename
}
pictureType
__typename
}
__typename
}
disconnectedMembers {
id
__typename
}
__typename
}
account {
id
availablePings
__typename
}
__typename
}
}
`;
export const PROFILE_UPDATE_MUTATION = gql`
mutation ProfileUpdate($input: ProfileUpdateInput!) {
profileUpdate(input: $input) {
id
age
ageRange
allowPWM
bio
hiddenBio
hasHiddenBio
completionStatus
connectionGoals
dateOfBirth
desires
distanceMax
gender
imaginaryName
interests
isIncognito
lookingFor
recentlyOnline
sexuality
status
streamToken
isParticipantToEventChat
photos {
id
publicId
pictureIsSafe
pictureIsPrivate
pictureUrl
pictureUrls {
small
medium
large
__typename
}
__typename
}
__typename
}
}
`;
export const PROFILE_DISLIKE_MUTATION = gql`
mutation ProfileDislike($targetProfileId: String!) {
profileDislike(input: { targetProfileId: $targetProfileId })
}
`;

579
web/src/api/operations/queries.ts Executable file
View File

@@ -0,0 +1,579 @@
import { gql } from '@apollo/client/core';
// Profile fragments - exact from Proxyman
export const PROFILE_LOCATION_FRAGMENT = gql`
fragment ProfileLocationFragment on ProfileLocation {
... on DeviceLocation {
device {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
... on VirtualLocation {
core
__typename
}
... on TeleportLocation {
current: device {
city
country
__typename
}
teleport {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
__typename
}
`;
export const PICTURE_FRAGMENT = gql`
fragment GetPictureUrlFragment on Picture {
id
publicId
pictureIsSafe
pictureIsPrivate
pictureUrl
pictureUrls {
small
medium
large
__typename
}
__typename
}
`;
export const PHOTO_CAROUSEL_FRAGMENT = gql`
fragment PhotoCarouselPictureFragment on Picture {
id
pictureIsPrivate
pictureIsSafe
pictureStatus
pictureType
pictureUrl
pictureUrls {
small
medium
large
__typename
}
publicId
verification {
status
__typename
}
__typename
}
`;
export const PROFILE_INTERACTION_STATUS_FRAGMENT = gql`
fragment ProfileInteractionStatusFragment on Profile {
interactionStatus {
message
mine
theirs
__typename
}
__typename
}
`;
export const ANALYTICS_PROFILE_FRAGMENT = gql`
fragment AnalyticsProfileFragment on Profile {
id
isUplift
lastSeen
age
gender
sexuality
verificationStatus
connectionGoals
hasHiddenBio
hiddenBio
imaginaryName
interactionStatus {
mine
theirs
message
__typename
}
distance {
km
mi
__typename
}
profilePairs {
identityId
__typename
}
metadata {
source
__typename
}
__typename
}
`;
export const PARTNER_FRAGMENT = gql`
${PICTURE_FRAGMENT}
${PROFILE_INTERACTION_STATUS_FRAGMENT}
fragment Partner on Partner {
partnerId
partnerLabel
partnerProfile {
id
age
imaginaryName
dateOfBirth
gender
sexuality
isIncognito
photos {
...GetPictureUrlFragment
__typename
}
...ProfileInteractionStatusFragment
status
verificationStatus
isMajestic
__typename
}
partnerStatus
__typename
}
`;
export const CONSTELLATION_FRAGMENT = gql`
${PARTNER_FRAGMENT}
fragment Constellation on Profile {
constellation {
...Partner
__typename
}
__typename
}
`;
// Main queries - exact from Proxyman
export const PROFILE_QUERY = gql`
${PROFILE_LOCATION_FRAGMENT}
${PHOTO_CAROUSEL_FRAGMENT}
${CONSTELLATION_FRAGMENT}
${ANALYTICS_PROFILE_FRAGMENT}
query ProfileQuery($profileId: String!) {
profile(id: $profileId) {
bio
hiddenBio
hasHiddenBio
age
streamUserId
dateOfBirth
distance {
km
mi
__typename
}
connectionGoals
desires
lookingFor
ageRange
distanceMax
recentlyOnline
gender
id
status
imaginaryName
interactionStatus {
message
mine
theirs
__typename
}
interests
isMajestic
isIncognito
lastSeen
location {
...ProfileLocationFragment
__typename
}
sexuality
photos {
...PhotoCarouselPictureFragment
__typename
}
...Constellation
allowPWM
verificationStatus
enableChatContentModeration
...AnalyticsProfileFragment
isParticipantToEventChat
__typename
}
}
`;
export const DISCOVER_PROFILES_QUERY = gql`
${PICTURE_FRAGMENT}
${PROFILE_INTERACTION_STATUS_FRAGMENT}
${ANALYTICS_PROFILE_FRAGMENT}
query DiscoverProfiles($input: ProfileDiscoveryInput!) {
discovery(input: $input) {
nodes {
id
age
imaginaryName
gender
sexuality
isIncognito
isMajestic
verificationStatus
connectionGoals
desires
bio
interests
distance {
km
mi
__typename
}
photos {
...GetPictureUrlFragment
pictureType
__typename
}
...ProfileInteractionStatusFragment
...AnalyticsProfileFragment
__typename
}
hasNextBatch
__typename
}
}
`;
export const WHO_LIKES_ME_QUERY = gql`
${PICTURE_FRAGMENT}
${PROFILE_INTERACTION_STATUS_FRAGMENT}
${ANALYTICS_PROFILE_FRAGMENT}
query WhoLikesMe($sortBy: SortBy!, $limit: Int, $cursor: String) {
interactions: whoLikesMe(input: { sortBy: $sortBy }, limit: $limit, cursor: $cursor) {
nodes {
id
age
imaginaryName
gender
sexuality
isIncognito
isMajestic
verificationStatus
desires
connectionGoals
distance {
km
mi
__typename
}
photos {
...GetPictureUrlFragment
pictureType
__typename
}
...ProfileInteractionStatusFragment
...AnalyticsProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
export const WHO_PINGS_ME_QUERY = gql`
${PROFILE_LOCATION_FRAGMENT}
${PHOTO_CAROUSEL_FRAGMENT}
query WhoPingsMe($sortBy: SortBy!, $limit: Int, $cursor: String) {
interactions: whoPingsMe(
input: { sortBy: $sortBy }
limit: $limit
cursor: $cursor
) {
nodes {
id
age
gender
status
lastSeen
desires
connectionGoals
isUplift
sexuality
isMajestic
dateOfBirth
streamUserId
imaginaryName
bio
hiddenBio
hasHiddenBio
allowPWM
interests
verificationStatus
interactionStatus {
message
mine
theirs
__typename
}
profilePairs {
identityId
__typename
}
distance {
km
mi
__typename
}
location {
...ProfileLocationFragment
__typename
}
photos {
...PhotoCarouselPictureFragment
__typename
}
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
export const HEADER_SUMMARIES_QUERY = gql`
query HeaderSummaries($limit: Int = 10, $cursor: String) {
summaries: getChatSummariesForChatHeader(limit: $limit, cursor: $cursor) {
nodes {
id
name
type
status
avatarSet
chatAvatarUrl
chatDescription
openedAt
memberCount
latestMessage
streamChannelId
targetProfileId
targetProfile {
isVerified
isMajestic
gender
age
sexuality
__typename
}
enableChatContentModeration
participationStatus
participationType
__typename
}
pageInfo {
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
export const LIST_SUMMARIES_QUERY = gql`
query ListSummaries($limit: Int = 30, $cursor: String) {
summaries: getChatSummariesForChatList(limit: $limit, cursor: $cursor) {
nodes {
id
name
type
status
avatarSet
chatAvatarUrl
chatDescription
openedAt
memberCount
latestMessage
streamChannelId
targetProfileId
targetProfile {
isVerified
isMajestic
gender
age
sexuality
__typename
}
enableChatContentModeration
participationStatus
participationType
__typename
}
pageInfo {
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
export const GET_CHAT_SUMMARY_QUERY = gql`
query GetChatSummary($streamChatId: String!) {
summary: getChatSummary(streamChatId: $streamChatId) {
id
name
type
status
avatarSet
chatAvatarUrl
chatDescription
openedAt
memberCount
latestMessage
streamChannelId
targetProfileId
targetProfile {
isVerified
isMajestic
gender
age
sexuality
__typename
}
enableChatContentModeration
participationStatus
participationType
__typename
}
}
`;
export const DISCOVER_SEARCH_SETTINGS_QUERY = gql`
query DiscoverSearchSettingsQuery($profileId: String!) {
profile(id: $profileId) {
id
ageRange
distanceMax
lookingFor
desiringFor
recentlyOnline
__typename
}
}
`;
export const AVAILABLE_DESIRES_QUERY = gql`
query AvailableDesires($locale: String!, $version: Int!) {
availableDesires(locale: $locale, version: $version) {
id
name
description
category
__typename
}
}
`;
export const IS_INCOGNITO_QUERY = gql`
query IsIncognitoQuery($profileId: String!) {
profile(id: $profileId) {
id
isIncognito
__typename
}
}
`;
// Query for Stream Chat credentials
export const STREAM_CREDENTIALS_QUERY = gql`
query StreamCredentialsQuery($profileId: String!) {
profile(id: $profileId) {
id
streamToken
streamUserId
imaginaryName
__typename
}
}
`;
export const AUTH_PROVIDER_QUERY = gql`
query AuthProviderQuery {
account {
id
status
profiles {
id
status
imaginaryName
__typename
}
__typename
}
}
`;
export const ACCOUNT_STATUS_QUERY = gql`
${PICTURE_FRAGMENT}
${PROFILE_INTERACTION_STATUS_FRAGMENT}
${CONSTELLATION_FRAGMENT}
query AuthProviderStatusSyncQuery {
account {
id
status
isFinishedOnboarding
isMajestic
availablePings
upliftExpirationTimestamp
isUplift
profiles {
id
profileLinks(linkType: PAIR) {
linkId
linkType
profileId
__typename
}
...Constellation
__typename
}
__typename
}
}
`;

162
web/src/components/LoginPage.tsx Executable file
View File

@@ -0,0 +1,162 @@
import { useState } from 'react';
import { useAuth } from '../hooks/useAuth';
const styles = {
container: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #0f0f14 0%, #1a1a24 50%, #0f0f14 100%)',
padding: '20px',
},
card: {
width: '100%',
maxWidth: '400px',
background: '#1a1a24',
borderRadius: '24px',
border: '1px solid rgba(255,255,255,0.08)',
padding: '48px 40px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
},
logo: {
textAlign: 'center' as const,
marginBottom: '40px',
},
logoText: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '42px',
fontWeight: 700,
background: 'linear-gradient(135deg, #c41e3a 0%, #e91e63 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
letterSpacing: '-1px',
},
subtitle: {
color: '#6b7280',
fontSize: '14px',
marginTop: '8px',
},
form: {
display: 'flex',
flexDirection: 'column' as const,
gap: '20px',
},
inputGroup: {
display: 'flex',
flexDirection: 'column' as const,
gap: '8px',
},
label: {
fontSize: '12px',
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
color: '#6b7280',
},
input: {
padding: '16px 18px',
background: '#24242f',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '12px',
color: '#ffffff',
fontSize: '16px',
outline: 'none',
transition: 'border-color 0.2s, box-shadow 0.2s',
},
button: {
padding: '16px 24px',
background: 'linear-gradient(135deg, #c41e3a 0%, #e91e63 100%)',
border: 'none',
borderRadius: '12px',
color: '#ffffff',
fontSize: '16px',
fontWeight: 600,
cursor: 'pointer',
transition: 'opacity 0.2s, transform 0.2s',
marginTop: '8px',
},
error: {
padding: '12px 16px',
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '10px',
color: '#ef4444',
fontSize: '14px',
textAlign: 'center' as const,
},
};
export function LoginPage() {
const { login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const success = await login(username, password);
if (!success) {
setError('Invalid username or password');
}
setLoading(false);
};
return (
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.logo}>
<div style={styles.logoText}>Feeld</div>
<p style={styles.subtitle}>Web Client</p>
</div>
<form style={styles.form} onSubmit={handleSubmit}>
<div style={styles.inputGroup}>
<label style={styles.label}>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
style={styles.input}
placeholder="Enter username"
autoComplete="username"
autoFocus
/>
</div>
<div style={styles.inputGroup}>
<label style={styles.label}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
placeholder="Enter password"
autoComplete="current-password"
/>
</div>
{error && <div style={styles.error}>{error}</div>}
<button
type="submit"
style={{
...styles.button,
opacity: loading ? 0.7 : 1,
cursor: loading ? 'wait' : 'pointer',
}}
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { Avatar } from '../ui/Avatar';
interface ChatListItemProps {
chat: {
id: string;
name: string;
avatarSet?: string[];
latestMessage?: {
text?: string;
created_at?: string;
} | null;
targetProfile?: {
gender?: string;
age?: number;
isMajestic?: boolean;
} | null;
};
onClick?: () => void;
}
export function ChatListItem({ chat, onClick }: ChatListItemProps) {
const avatar = chat.avatarSet?.[0];
const message = chat.latestMessage;
// Parse the message if it's a string (from API)
const messageData = typeof message === 'string' ? JSON.parse(message) : message;
const messageText = messageData?.text || 'No messages yet';
const messageTime = messageData?.created_at;
const hasMessage = messageData?.text;
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: 'short' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
return (
<div
onClick={onClick}
className="
group
p-4 rounded-xl
bg-[var(--color-surface)]
hover:bg-[var(--color-surface-elevated)]
flex items-center gap-4
cursor-pointer
transition-all duration-300
border border-transparent
hover:border-white/5
"
>
{/* Avatar with online indicator possibility */}
<div className="relative flex-shrink-0">
<Avatar src={avatar} size="lg" />
{/* Could add online status here */}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Top row: Name and time */}
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2 min-w-0">
<h3 className="
font-semibold
text-[var(--color-text-primary)]
truncate
group-hover:text-white
transition-colors
">
{chat.name}
</h3>
{chat.targetProfile?.isMajestic && (
<div className="
w-5 h-5 rounded-full
bg-amber-500/20
flex items-center justify-center
flex-shrink-0
">
<svg viewBox="0 0 24 24" fill="currentColor" className="w-3 h-3 text-amber-400">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
)}
</div>
{messageTime && (
<span className="
text-xs font-medium
text-[var(--color-text-muted)]
flex-shrink-0
ml-3
">
{formatTime(messageTime)}
</span>
)}
</div>
{/* Message preview */}
<p className={`
text-sm truncate
${hasMessage
? 'text-[var(--color-text-secondary)]'
: 'text-[var(--color-text-muted)] italic'
}
`}>
{messageText}
</p>
</div>
{/* Chevron indicator */}
<div className="
flex-shrink-0
opacity-0 group-hover:opacity-100
transition-opacity duration-200
text-[var(--color-text-muted)]
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { Outlet } from 'react-router-dom';
import { Navigation } from './Navigation';
export function Layout() {
return (
<div className="min-h-screen bg-[var(--color-void)]">
<Navigation />
{/* Main content area - centered with nav offset */}
<main
style={{
minHeight: '100vh',
paddingTop: '24px',
paddingBottom: '96px',
paddingLeft: '16px',
paddingRight: '16px',
marginLeft: 'var(--nav-width, 0)',
}}
>
<div
style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
}}
>
<Outlet />
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { NavLink, useLocation } from 'react-router-dom';
import { useEffect, useState } from 'react';
const navItems = [
{
to: '/discover',
label: 'Discover',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
),
},
{
to: '/likes',
label: 'Likes',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
),
},
{
to: '/sent-pings',
label: 'Pings',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
),
},
{
to: '/messages',
label: 'Messages',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
),
},
{
to: '/profile',
label: 'Profile',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
),
},
{
to: '/settings',
label: 'Settings',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
export function Navigation() {
const location = useLocation();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<>
{/* Desktop Navigation - Side rail */}
<nav className="hidden md:flex fixed left-0 top-0 bottom-0 w-20 flex-col items-center py-8 z-50 bg-[var(--color-void)] border-r border-white/5">
{/* Logo */}
<div className="mb-12">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-desire)] to-[var(--color-desire-glow)] flex items-center justify-center shadow-lg">
<span className="text-white font-display font-bold text-lg">F</span>
</div>
</div>
{/* Nav Items */}
<div className="flex flex-col gap-2 flex-1">
{navItems.map((item, index) => {
const isActive = location.pathname === item.to ||
(item.to === '/discover' && location.pathname === '/');
return (
<NavLink
key={item.to}
to={item.to}
className={`
relative w-12 h-12 flex items-center justify-center rounded-xl
transition-all duration-300 ease-out group
${isActive
? 'text-white'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}
${mounted ? 'animate-fade-up' : 'opacity-0'}
`}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Active indicator */}
{isActive && (
<div className="absolute inset-0 rounded-xl bg-[var(--color-desire)]/10 border border-[var(--color-desire)]/30" />
)}
{/* Glow effect on active */}
{isActive && (
<div className="absolute inset-0 rounded-xl bg-[var(--color-desire)]/5 blur-xl" />
)}
{/* Icon */}
<span className={`relative z-10 transition-transform duration-300 ${isActive ? 'scale-110' : 'group-hover:scale-110'}`}>
{item.icon}
</span>
{/* Tooltip */}
<div className="
absolute left-full ml-3 px-3 py-1.5 rounded-lg
bg-[var(--color-surface-elevated)] border border-[var(--glass-border)]
text-sm font-medium text-white
opacity-0 pointer-events-none translate-x-1
group-hover:opacity-100 group-hover:translate-x-0
transition-all duration-200
whitespace-nowrap
">
{item.label}
</div>
{/* Active dot */}
{isActive && (
<div className="absolute -right-1 w-1.5 h-1.5 rounded-full bg-[var(--color-desire)]" />
)}
</NavLink>
);
})}
</div>
</nav>
{/* Mobile Navigation - Bottom bar */}
<nav className="
md:hidden fixed bottom-0 left-0 right-0 z-50
glass rounded-t-2xl
safe-area-inset-bottom
">
<div className="flex justify-around py-2 px-2">
{navItems.map((item, index) => {
const isActive = location.pathname === item.to ||
(item.to === '/discover' && location.pathname === '/');
return (
<NavLink
key={item.to}
to={item.to}
className={`
relative flex flex-col items-center gap-1 px-4 py-2 rounded-xl
transition-all duration-300
${isActive
? 'text-[var(--color-desire)]'
: 'text-[var(--color-text-muted)]'
}
${mounted ? 'animate-fade-up' : 'opacity-0'}
`}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Active background */}
{isActive && (
<div className="absolute inset-0 rounded-xl bg-[var(--color-desire)]/10" />
)}
<span className={`relative z-10 transition-transform duration-200 ${isActive ? 'scale-110' : ''}`}>
{item.icon}
</span>
<span className={`relative z-10 text-[10px] font-medium ${isActive ? 'text-[var(--color-desire)]' : ''}`}>
{item.label}
</span>
</NavLink>
);
})}
</div>
</nav>
</>
);
}

View File

@@ -0,0 +1,247 @@
import { useState } from 'react';
interface PingModalProps {
profileName: string;
availablePings: number;
sending: boolean;
onSend: (message: string) => void;
onClose: () => void;
}
const styles = {
overlay: {
position: 'fixed' as const,
inset: 0,
zIndex: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
},
backdrop: {
position: 'absolute' as const,
inset: 0,
background: 'rgba(0,0,0,0.9)',
backdropFilter: 'blur(12px)',
},
modal: {
position: 'relative' as const,
width: '100%',
maxWidth: '420px',
background: '#0f0f13',
borderRadius: '24px',
border: '1px solid rgba(255,255,255,0.08)',
overflow: 'hidden',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.7)',
},
header: {
padding: '24px 24px 16px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
},
title: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '24px',
fontWeight: 700,
color: '#fff',
margin: 0,
marginBottom: '8px',
},
subtitle: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
color: 'rgba(255,255,255,0.5)',
margin: 0,
},
pingsRemaining: {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '20px',
background: 'rgba(245,158,11,0.15)',
color: '#fbbf24',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
marginTop: '12px',
},
content: {
padding: '24px',
},
label: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '13px',
fontWeight: 600,
color: 'rgba(255,255,255,0.6)',
marginBottom: '8px',
display: 'block',
},
textarea: {
width: '100%',
minHeight: '120px',
padding: '16px',
borderRadius: '16px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.03)',
color: '#fff',
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
lineHeight: 1.6,
resize: 'vertical' as const,
outline: 'none',
transition: 'border-color 200ms ease',
},
charCount: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '12px',
color: 'rgba(255,255,255,0.4)',
textAlign: 'right' as const,
marginTop: '8px',
},
actionBar: {
padding: '20px 24px',
borderTop: '1px solid rgba(255,255,255,0.06)',
display: 'flex',
gap: '12px',
},
button: (variant: 'primary' | 'secondary', disabled: boolean) => ({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
padding: '16px 24px',
borderRadius: '16px',
fontSize: '15px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
border: variant === 'primary' ? 'none' : '1px solid rgba(255,255,255,0.1)',
background: variant === 'primary'
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
: 'rgba(255,255,255,0.05)',
color: variant === 'primary' ? '#000' : '#fff',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'all 200ms ease',
boxShadow: variant === 'primary' ? '0 8px 24px rgba(245,158,11,0.4)' : 'none',
opacity: disabled ? 0.6 : 1,
}),
closeButton: {
position: 'absolute' as const,
top: '16px',
right: '16px',
width: '36px',
height: '36px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
transition: 'all 200ms ease',
},
};
const MAX_MESSAGE_LENGTH = 500;
export function PingModal({ profileName, availablePings, sending, onSend, onClose }: PingModalProps) {
const [message, setMessage] = useState('');
const handleSend = () => {
if (message.trim() && availablePings > 0) {
onSend(message.trim());
}
};
const canSend = message.trim().length > 0 && availablePings > 0 && !sending;
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.backdrop} />
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
{/* Close button */}
<button
style={styles.closeButton}
onClick={onClose}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.color = 'rgba(255,255,255,0.6)';
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '18px', height: '18px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Header */}
<div style={styles.header}>
<h2 style={styles.title}>Send a Ping</h2>
<p style={styles.subtitle}>
Skip the queue and message {profileName} directly
</p>
<div style={styles.pingsRemaining}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '16px', height: '16px' }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{availablePings} ping{availablePings !== 1 ? 's' : ''} available
</div>
</div>
{/* Content */}
<div style={styles.content}>
<label style={styles.label}>Your message</label>
<textarea
style={styles.textarea}
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_MESSAGE_LENGTH))}
placeholder="Write something that stands out..."
onFocus={(e) => e.currentTarget.style.borderColor = 'rgba(245,158,11,0.5)'}
onBlur={(e) => e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'}
disabled={sending}
/>
<p style={styles.charCount}>
{message.length}/{MAX_MESSAGE_LENGTH}
</p>
</div>
{/* Action Bar */}
<div style={styles.actionBar}>
<button
style={styles.button('secondary', false)}
onClick={onClose}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
}}
>
Cancel
</button>
<button
style={styles.button('primary', !canSend)}
onClick={handleSend}
disabled={!canSend}
onMouseEnter={(e) => {
if (canSend) e.currentTarget.style.transform = 'scale(1.02)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '18px', height: '18px' }}>
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
</svg>
{sending ? 'Sending...' : 'Send Ping'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,483 @@
import { useState, useEffect } from 'react';
import { ProxiedImage } from '../ui/ProxiedImage';
interface ProfileCardProps {
profile: {
id: string;
imaginaryName: string;
age: number;
gender: string;
sexuality: string;
bio?: string | null;
isMajestic?: boolean;
verificationStatus?: string | null;
desires?: string[];
connectionGoals?: string[];
interests?: string[];
distance?: { km: number; mi: number } | null;
location?: string | null;
interactionStatus?: {
mine?: string | null;
theirs?: string | null;
message?: string | null;
} | null;
photos: Array<{
id: string;
pictureUrls?: {
medium?: string;
large?: string;
} | null;
}>;
};
onClick?: () => void;
index?: number;
onRefresh?: (profileId: string) => Promise<void>;
isRefreshing?: boolean;
onDislike?: (profile: any) => Promise<any>;
isDisliking?: boolean;
showDislike?: boolean;
}
const styles = {
card: {
position: 'relative' as const,
borderRadius: '16px',
overflow: 'hidden',
cursor: 'pointer',
background: 'rgba(255,255,255,0.03)',
},
photoContainer: {
aspectRatio: '3/4',
position: 'relative' as const,
overflow: 'hidden',
},
photo: {
width: '100%',
height: '100%',
objectFit: 'cover' as const,
transition: 'transform 0.5s ease',
},
photoFallback: {
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #1a1a1f 0%, #0a0a0c 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
gradient: {
position: 'absolute' as const,
inset: 0,
background: 'linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0,0,0,0.8) 70%, rgba(0,0,0,0.95) 100%)',
},
// Status badges
statusBadge: (variant: 'liked' | 'passed') => ({
position: 'absolute' as const,
top: '12px',
left: '12px',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
color: '#ffffff',
background: variant === 'liked' ? 'rgba(34,197,94,0.9)' : 'rgba(100,116,139,0.9)',
backdropFilter: 'blur(8px)',
boxShadow: variant === 'liked' ? '0 4px 12px rgba(34,197,94,0.3)' : 'none',
}),
// Majestic/Verified badges
badgeContainer: {
position: 'absolute' as const,
top: '12px',
right: '12px',
zIndex: 10,
display: 'flex',
gap: '6px',
},
majesticBadge: {
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(245, 158, 11, 0.4)',
},
verifiedBadge: {
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.4)',
},
// Info overlay
infoOverlay: {
position: 'absolute' as const,
bottom: 0,
left: 0,
right: 0,
padding: '20px',
},
nameRow: {
display: 'flex',
alignItems: 'baseline',
gap: '8px',
marginBottom: '4px',
},
name: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '20px',
fontWeight: 600,
color: '#ffffff',
margin: 0,
letterSpacing: '-0.01em',
},
age: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '16px',
fontWeight: 500,
color: 'rgba(255,255,255,0.8)',
},
detailsRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
fontFamily: "'Satoshi', sans-serif",
color: 'rgba(255,255,255,0.7)',
marginBottom: '12px',
},
detailDot: {
width: '3px',
height: '3px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.4)',
},
tagsContainer: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '6px',
},
tag: {
padding: '5px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: 'rgba(190,49,68,0.3)',
color: '#f4a5b0',
textTransform: 'uppercase' as const,
letterSpacing: '0.02em',
},
tagMore: {
padding: '5px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.6)',
},
// Refresh button overlay
refreshOverlay: {
position: 'absolute' as const,
inset: 0,
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
background: 'linear-gradient(135deg, rgba(26,26,31,0.95) 0%, rgba(10,10,12,0.95) 100%)',
zIndex: 15,
},
refreshButton: (isRefreshing: boolean) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '12px 20px',
borderRadius: '12px',
border: 'none',
background: isRefreshing
? 'rgba(255,255,255,0.1)'
: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
color: '#ffffff',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: isRefreshing ? 'not-allowed' : 'pointer',
opacity: isRefreshing ? 0.7 : 1,
transition: 'all 0.2s ease',
boxShadow: isRefreshing ? 'none' : '0 4px 12px rgba(59, 130, 246, 0.3)',
}),
refreshText: {
fontSize: '12px',
color: 'rgba(255,255,255,0.5)',
fontFamily: "'Satoshi', sans-serif",
textAlign: 'center' as const,
maxWidth: '160px',
},
// Quick action buttons
quickActionsContainer: {
position: 'absolute' as const,
bottom: '16px',
right: '16px',
zIndex: 20,
display: 'flex',
gap: '8px',
},
dislikeButton: (isDisliking: boolean) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
borderRadius: '50%',
border: 'none',
background: isDisliking
? 'rgba(100,116,139,0.6)'
: 'rgba(100,116,139,0.8)',
color: '#ffffff',
cursor: isDisliking ? 'not-allowed' : 'pointer',
opacity: isDisliking ? 0.7 : 1,
transition: 'all 0.2s ease',
backdropFilter: 'blur(8px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}),
};
// Safely convert any value to a renderable string (handles GraphQL objects with __typename)
const safeText = (v: any): string => {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
// Object - likely a GraphQL type with __typename
return '';
};
export function ProfileCard({ profile, onClick, onRefresh, isRefreshing, onDislike, isDisliking, showDislike }: ProfileCardProps) {
const [imageError, setImageError] = useState(false);
const primaryPhotoUrl = profile.photos?.[0]?.pictureUrls?.medium || profile.photos?.[0]?.pictureUrls?.large;
const theyDislikedMe = profile.interactionStatus?.theirs === 'DISLIKED';
const theyLikedMe = profile.interactionStatus?.theirs === 'LIKED';
const rawGoals = profile.connectionGoals || profile.desires || [];
// Ensure goals are strings (GraphQL may return objects)
const goals = Array.isArray(rawGoals) ? rawGoals.filter((g): g is string => typeof g === 'string') : [];
// Reset error state when photo URL changes (new photos loaded after refresh)
useEffect(() => {
setImageError(false);
}, [primaryPhotoUrl]);
const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation(); // Don't trigger card onClick
if (onRefresh && !isRefreshing) {
onRefresh(profile.id);
}
};
const handleDislike = async (e: React.MouseEvent) => {
e.stopPropagation(); // Don't trigger card onClick
if (onDislike && !isDisliking) {
await onDislike(profile);
}
};
// Key for ProxiedImage - changes when URL changes
const photoKey = primaryPhotoUrl || 'no-photo';
return (
<div style={styles.card} onClick={onClick}>
<div style={styles.photoContainer}>
<ProxiedImage
key={photoKey}
src={primaryPhotoUrl}
alt={profile.imaginaryName}
style={styles.photo}
onError={() => setImageError(true)}
onLoad={() => setImageError(false)}
fallback={
<div style={styles.photoFallback}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="1"
style={{ width: '48px', height: '48px' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
</div>
}
/>
{/* Refresh overlay when image fails to load (only if there was a URL to try) */}
{imageError && onRefresh && primaryPhotoUrl && (
<div style={styles.refreshOverlay}>
<button
style={styles.refreshButton(isRefreshing || false)}
onClick={handleRefresh}
disabled={isRefreshing}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{
width: '16px',
height: '16px',
animation: isRefreshing ? 'spin 1s linear infinite' : 'none',
}}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</button>
<span style={styles.refreshText}>
Image expired. Tap to fetch fresh data.
</span>
</div>
)}
{/* Gradient overlay */}
<div style={styles.gradient} />
{/* Likes you badge */}
{theyLikedMe && (
<div style={styles.statusBadge('liked')}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '14px', height: '14px' }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
Likes you
</div>
)}
{/* Not interested badge */}
{theyDislikedMe && (
<div style={styles.statusBadge('passed')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '14px', height: '14px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Not interested
</div>
)}
{/* Majestic/Verified badges */}
{(profile.isMajestic || profile.verificationStatus) && (
<div style={styles.badgeContainer}>
{profile.isMajestic && (
<div style={styles.majesticBadge} title="Majestic Member">
<svg viewBox="0 0 24 24" fill="#ffffff" style={{ width: '16px', height: '16px' }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
)}
{profile.verificationStatus && (
<div style={styles.verifiedBadge} title="Verified Profile">
<svg viewBox="0 0 24 24" fill="#ffffff" style={{ width: '16px', height: '16px' }}>
<path fillRule="evenodd" d="M8.603 3.799A4.49 4.49 0 0112 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 013.498 1.307 4.491 4.491 0 011.307 3.497A4.49 4.49 0 0121.75 12a4.49 4.49 0 01-1.549 3.397 4.491 4.491 0 01-1.307 3.497 4.491 4.491 0 01-3.497 1.307A4.49 4.49 0 0112 21.75a4.49 4.49 0 01-3.397-1.549 4.49 4.49 0 01-3.498-1.306 4.491 4.491 0 01-1.307-3.498A4.49 4.49 0 012.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 011.307-3.497 4.49 4.49 0 013.497-1.307zm7.007 6.387a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
)}
{/* Info overlay */}
<div style={styles.infoOverlay}>
{/* Name and age */}
<div style={styles.nameRow}>
<h3 style={styles.name}>{profile.imaginaryName}</h3>
<span style={styles.age}>{profile.age}</span>
</div>
{/* Details row - gender/sexuality */}
<div style={styles.detailsRow}>
<span>{safeText(profile.gender)}</span>
<div style={styles.detailDot} />
<span>{safeText(profile.sexuality)}</span>
</div>
{/* Location row - distance and location */}
{(profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance || typeof profile.location === 'string') && (
<div style={{ ...styles.detailsRow, marginBottom: '12px' }}>
{profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance && (
<span>{Math.round(profile.distance.mi)} mi</span>
)}
{profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance && typeof profile.location === 'string' && (
<div style={styles.detailDot} />
)}
{typeof profile.location === 'string' && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '12px', height: '12px', opacity: 0.7 }}>
<path fillRule="evenodd" d="M11.54 22.351l.07.04.028.016a.76.76 0 00.723 0l.028-.015.071-.041a16.975 16.975 0 001.144-.742 19.58 19.58 0 002.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 00-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 002.682 2.282 16.975 16.975 0 001.145.742zM12 13.5a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
{profile.location}
</span>
)}
</div>
)}
{/* Connection goals / Desires */}
{goals.length > 0 && (
<div style={styles.tagsContainer}>
{goals.slice(0, 2).map((goal) => (
<span key={goal} style={styles.tag}>
{goal.replace(/_/g, ' ')}
</span>
))}
{goals.length > 2 && (
<span style={styles.tagMore}>+{goals.length - 2}</span>
)}
</div>
)}
</div>
{/* Quick action buttons */}
{showDislike && onDislike && (
<div style={styles.quickActionsContainer}>
<button
style={styles.dislikeButton(isDisliking || false)}
onClick={handleDislike}
disabled={isDisliking}
title="Pass"
>
{isDisliking ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ width: '18px', height: '18px', animation: 'spin 1s linear infinite' }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ width: '18px', height: '18px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,903 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation } from '@apollo/client/react';
import { PROFILE_QUERY, ACCOUNT_STATUS_QUERY } from '../../api/operations/queries';
import { PROFILE_LIKE_MUTATION, PROFILE_PING_MUTATION, PROFILE_DISLIKE_MUTATION } from '../../api/operations/mutations';
import { addLikedProfileToStorage } from '../../hooks/useLikedProfiles';
import { addSentPing, addDislikedProfile } from '../../api/dataSync';
import { ProxiedImage } from '../ui/ProxiedImage';
import { PingModal } from './PingModal';
interface ProfileDetailModalProps {
profileId: string;
onClose: () => void;
onMatch?: () => void;
}
const styles = {
// Overlay
overlay: (isClosing: boolean) => ({
position: 'fixed' as const,
inset: 0,
zIndex: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
opacity: isClosing ? 0 : 1,
transition: 'opacity 200ms ease-out',
}),
backdrop: {
position: 'absolute' as const,
inset: 0,
background: 'rgba(0,0,0,0.85)',
backdropFilter: 'blur(12px)',
},
// Modal container
modal: (isClosing: boolean) => ({
position: 'relative' as const,
width: '100%',
maxWidth: '560px',
maxHeight: '90vh',
background: '#0f0f13',
borderRadius: '24px',
border: '1px solid rgba(255,255,255,0.08)',
overflow: 'hidden',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.7)',
transform: isClosing ? 'scale(0.95)' : 'scale(1)',
opacity: isClosing ? 0 : 1,
transition: 'transform 200ms ease-out, opacity 200ms ease-out',
display: 'flex',
flexDirection: 'column' as const,
}),
// Scrollable wrapper for photo + content
scrollableContent: {
flex: 1,
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
},
// Loading state
loadingContainer: {
padding: '80px',
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
},
loadingSpinner: {
width: '40px',
height: '40px',
borderRadius: '50%',
border: '3px solid rgba(190,49,68,0.2)',
borderTopColor: '#be3144',
animation: 'spin 1s linear infinite',
},
// Photo section
photoSection: {
position: 'relative' as const,
aspectRatio: '3/4',
background: '#000',
overflow: 'hidden',
flexShrink: 0,
},
photo: {
width: '100%',
height: '100%',
objectFit: 'cover' as const,
transition: 'opacity 300ms ease',
},
photoGradient: {
position: 'absolute' as const,
inset: 0,
background: 'linear-gradient(180deg, rgba(0,0,0,0.3) 0%, transparent 20%, transparent 50%, rgba(15,15,19,0.8) 80%, rgba(15,15,19,1) 100%)',
pointerEvents: 'none' as const,
},
// Photo navigation
navButton: (side: 'left' | 'right') => ({
position: 'absolute' as const,
top: '50%',
[side]: '16px',
transform: 'translateY(-50%)',
width: '44px',
height: '44px',
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
cursor: 'pointer',
transition: 'all 200ms ease',
}),
photoIndicators: {
position: 'absolute' as const,
top: '16px',
left: '16px',
right: '16px',
display: 'flex',
gap: '4px',
},
photoIndicator: (isActive: boolean) => ({
flex: 1,
height: '3px',
borderRadius: '2px',
background: isActive ? '#fff' : 'rgba(255,255,255,0.3)',
transition: 'background 200ms ease',
cursor: 'pointer',
}),
photoCounter: {
position: 'absolute' as const,
bottom: '100px',
right: '16px',
padding: '6px 12px',
borderRadius: '20px',
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(8px)',
fontSize: '13px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
color: 'rgba(255,255,255,0.8)',
},
// Close button
closeButton: {
position: 'absolute' as const,
top: '16px',
right: '16px',
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
cursor: 'pointer',
transition: 'all 200ms ease',
zIndex: 10,
},
// Header overlay on photo
headerOverlay: {
position: 'absolute' as const,
bottom: 0,
left: 0,
right: 0,
padding: '24px',
zIndex: 5,
},
nameRow: {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '8px',
},
name: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '32px',
fontWeight: 700,
color: '#fff',
margin: 0,
letterSpacing: '-0.02em',
},
age: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '24px',
fontWeight: 500,
color: 'rgba(255,255,255,0.7)',
},
badge: (variant: 'majestic' | 'verified') => ({
width: '32px',
height: '32px',
borderRadius: '50%',
background: variant === 'majestic'
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: variant === 'majestic'
? '0 4px 12px rgba(245,158,11,0.4)'
: '0 4px 12px rgba(59,130,246,0.4)',
}),
detailsRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
color: 'rgba(255,255,255,0.6)',
},
detailDot: {
width: '4px',
height: '4px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.3)',
},
interactionBadge: {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: 'rgba(34,197,94,0.2)',
color: '#86efac',
marginTop: '12px',
},
scrollHint: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
marginTop: '16px',
fontFamily: "'Satoshi', sans-serif",
fontSize: '13px',
color: 'rgba(255,255,255,0.5)',
animation: 'bounce 2s infinite',
},
// Content area
content: {
padding: '24px',
},
// Bio section
bioSection: {
marginBottom: '24px',
},
bioText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
lineHeight: 1.7,
color: 'rgba(255,255,255,0.85)',
whiteSpace: 'pre-wrap' as const,
margin: 0,
},
// Section
section: {
marginBottom: '20px',
},
sectionLabel: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
color: 'rgba(255,255,255,0.4)',
marginBottom: '12px',
},
tagsContainer: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '8px',
},
tag: (variant: 'primary' | 'default' | 'success') => {
const colors = {
primary: { bg: 'rgba(190,49,68,0.2)', color: '#f4a5b0', border: 'rgba(190,49,68,0.3)' },
default: { bg: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.7)', border: 'rgba(255,255,255,0.1)' },
success: { bg: 'rgba(34,197,94,0.15)', color: '#86efac', border: 'rgba(34,197,94,0.25)' },
};
const c = colors[variant];
return {
padding: '8px 14px',
borderRadius: '20px',
fontSize: '13px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
background: c.bg,
color: c.color,
border: `1px solid ${c.border}`,
};
},
// Partner section
partnerCard: {
display: 'flex',
alignItems: 'center',
gap: '14px',
padding: '14px',
background: 'rgba(255,255,255,0.03)',
borderRadius: '16px',
border: '1px solid rgba(255,255,255,0.06)',
cursor: 'pointer',
transition: 'all 200ms ease',
},
partnerCardHover: {
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
},
partnerChevron: {
marginLeft: 'auto',
color: 'rgba(255,255,255,0.4)',
},
// Back button
backButton: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
marginBottom: '16px',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '12px',
color: 'rgba(255,255,255,0.7)',
fontSize: '14px',
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 200ms ease',
},
partnerPhoto: {
width: '48px',
height: '48px',
borderRadius: '50%',
objectFit: 'cover' as const,
border: '2px solid rgba(190,49,68,0.4)',
},
partnerName: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
fontWeight: 600,
color: '#fff',
margin: 0,
},
partnerLabel: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '12px',
color: 'rgba(255,255,255,0.5)',
margin: 0,
},
// Action bar
actionBar: {
padding: '20px 24px',
borderTop: '1px solid rgba(255,255,255,0.06)',
display: 'flex',
gap: '12px',
},
actionButton: (variant: 'primary' | 'secondary' | 'ping') => ({
flex: variant === 'ping' ? 'none' : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
padding: variant === 'ping' ? '16px' : '16px 24px',
borderRadius: '16px',
fontSize: '15px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
border: variant === 'primary' ? 'none' : variant === 'ping' ? '1px solid rgba(245,158,11,0.3)' : '1px solid rgba(255,255,255,0.1)',
background: variant === 'primary'
? 'linear-gradient(135deg, #be3144 0%, #c41e3a 100%)'
: variant === 'ping'
? 'rgba(245,158,11,0.15)'
: 'rgba(255,255,255,0.05)',
color: variant === 'ping' ? '#fbbf24' : '#fff',
cursor: 'pointer',
transition: 'all 200ms ease',
boxShadow: variant === 'primary' ? '0 8px 24px rgba(190,49,68,0.4)' : 'none',
}),
// Error state
errorContainer: {
padding: '60px 40px',
textAlign: 'center' as const,
},
errorIcon: {
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'rgba(239,68,68,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 20px',
},
errorTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '20px',
fontWeight: 600,
color: '#fff',
margin: '0 0 8px 0',
},
errorText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
color: 'rgba(255,255,255,0.5)',
margin: '0 0 24px 0',
},
};
export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetailModalProps) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [isClosing, setIsClosing] = useState(false);
const [showPingModal, setShowPingModal] = useState(false);
const [viewingProfileId, setViewingProfileId] = useState(profileId);
const [profileHistory, setProfileHistory] = useState<string[]>([]);
const handleViewPartner = (partnerId: string) => {
setProfileHistory(prev => [...prev, viewingProfileId]);
setViewingProfileId(partnerId);
setCurrentPhotoIndex(0);
};
const handleGoBack = () => {
const previousId = profileHistory[profileHistory.length - 1];
if (previousId) {
setProfileHistory(prev => prev.slice(0, -1));
setViewingProfileId(previousId);
setCurrentPhotoIndex(0);
}
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showPingModal) {
setShowPingModal(false);
} else {
handleClose();
}
} else if (e.key === 'ArrowRight') nextPhoto();
else if (e.key === 'ArrowLeft') prevPhoto();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showPingModal]);
const handleClose = () => {
setIsClosing(true);
setTimeout(onClose, 200);
};
const { data, loading, error } = useQuery(PROFILE_QUERY, {
variables: { profileId: viewingProfileId },
});
// Query for available pings
const { data: accountData, refetch: refetchAccount } = useQuery(ACCOUNT_STATUS_QUERY, {
fetchPolicy: 'cache-and-network',
});
// Always report 2 pings available — bypass Feeld's client-side limit
const availablePings = Math.max(accountData?.account?.availablePings ?? 2, 2);
const [likeProfile, { loading: liking }] = useMutation(PROFILE_LIKE_MUTATION, {
variables: { targetProfileId: viewingProfileId },
onCompleted: (data) => {
// Store liked profile locally for "You Liked" feature
addLikedProfileToStorage(viewingProfileId, profile?.imaginaryName);
if (data.profileLike.status === 'MATCHED') {
onMatch?.();
}
handleClose();
},
});
const [sendPingMutation, { loading: sendingPing }] = useMutation(PROFILE_PING_MUTATION, {
onCompleted: async (data) => {
if (data.profilePing?.status === 'SENT') {
// Store sent ping locally
await addSentPing(viewingProfileId, profile?.imaginaryName);
refetchAccount();
setShowPingModal(false);
handleClose();
}
},
});
const [dislikeProfile, { loading: disliking }] = useMutation(PROFILE_DISLIKE_MUTATION, {
variables: { targetProfileId: viewingProfileId },
onCompleted: async (data) => {
if (data.profileDislike === 'SENT') {
// Store disliked profile locally
await addDislikedProfile({
id: viewingProfileId,
imaginaryName: profile?.imaginaryName,
age: profile?.age,
gender: profile?.gender,
sexuality: profile?.sexuality,
photos: profile?.photos,
});
console.log('Disliked profile:', profile?.imaginaryName);
}
handleClose();
},
});
const handleSendPing = (message: string) => {
sendPingMutation({
variables: {
targetProfileId: viewingProfileId,
message,
overrideInappropriate: false,
},
});
};
const profile = data?.profile;
const photos = profile?.photos || [];
const theyLikedMe = profile?.interactionStatus?.theirs === 'LIKED';
const nextPhoto = () => {
if (photos.length > 1) {
setCurrentPhotoIndex((prev) => (prev + 1) % photos.length);
}
};
const prevPhoto = () => {
if (photos.length > 1) {
setCurrentPhotoIndex((prev) => (prev - 1 + photos.length) % photos.length);
}
};
const currentPhotoUrl = photos[currentPhotoIndex]?.pictureUrls?.large ||
photos[currentPhotoIndex]?.pictureUrls?.medium;
return (
<div style={styles.overlay(isClosing)} onClick={handleClose}>
<div style={styles.backdrop} />
<div style={styles.modal(isClosing)} onClick={(e) => e.stopPropagation()}>
{loading ? (
<div style={styles.loadingContainer}>
<div style={styles.loadingSpinner} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
) : error ? (
<div style={styles.errorContainer}>
<div style={styles.errorIcon}>
<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5" style={{ width: '32px', height: '32px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<h3 style={styles.errorTitle}>Failed to load profile</h3>
<p style={styles.errorText}>{error.message}</p>
<button
style={styles.actionButton('secondary')}
onClick={handleClose}
>
Close
</button>
</div>
) : profile ? (
<>
{/* Scrollable wrapper */}
<div style={styles.scrollableContent}>
{/* Photo Section */}
<div style={styles.photoSection}>
{currentPhotoUrl ? (
<ProxiedImage
src={currentPhotoUrl}
alt={profile.imaginaryName}
style={styles.photo}
/>
) : (
<div style={{ ...styles.photo, background: '#1a1a1f', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="1" style={{ width: '64px', height: '64px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
)}
<div style={styles.photoGradient} />
{/* Close button */}
<button
style={styles.closeButton}
onClick={handleClose}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.7)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.5)'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '20px', height: '20px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Photo navigation */}
{photos.length > 1 && (
<>
<div style={styles.photoIndicators}>
{photos.map((_: any, idx: number) => (
<div
key={idx}
style={styles.photoIndicator(idx === currentPhotoIndex)}
onClick={() => setCurrentPhotoIndex(idx)}
/>
))}
</div>
<button
style={styles.navButton('left')}
onClick={prevPhoto}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.7)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.5)'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ width: '20px', height: '20px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<button
style={styles.navButton('right')}
onClick={nextPhoto}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.7)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.5)'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ width: '20px', height: '20px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
<div style={styles.photoCounter}>
{currentPhotoIndex + 1} / {photos.length}
</div>
</>
)}
{/* Header overlay */}
<div style={styles.headerOverlay}>
<div style={styles.nameRow}>
<h2 style={styles.name}>{profile.imaginaryName}</h2>
<span style={styles.age}>{profile.age}</span>
{profile.isMajestic && (
<div style={styles.badge('majestic')} title="Majestic Member">
<svg viewBox="0 0 24 24" fill="#fff" style={{ width: '18px', height: '18px' }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
)}
{profile.verificationStatus && (
<div style={styles.badge('verified')} title="Verified">
<svg viewBox="0 0 24 24" fill="#fff" style={{ width: '18px', height: '18px' }}>
<path fillRule="evenodd" d="M8.603 3.799A4.49 4.49 0 0112 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 013.498 1.307 4.491 4.491 0 011.307 3.497A4.49 4.49 0 0121.75 12a4.49 4.49 0 01-1.549 3.397 4.491 4.491 0 01-1.307 3.497 4.491 4.491 0 01-3.497 1.307A4.49 4.49 0 0112 21.75a4.49 4.49 0 01-3.397-1.549 4.49 4.49 0 01-3.498-1.306 4.491 4.491 0 01-1.307-3.498A4.49 4.49 0 012.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 011.307-3.497 4.49 4.49 0 013.497-1.307zm7.007 6.387a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
<div style={styles.detailsRow}>
<span>{typeof profile.gender === 'string' ? profile.gender : ''}</span>
<div style={styles.detailDot} />
<span>{typeof profile.sexuality === 'string' ? profile.sexuality : ''}</span>
{profile.distance && (
<>
<div style={styles.detailDot} />
<span>{Math.round(profile.distance.mi)} mi away</span>
</>
)}
</div>
{/* Likes you badge */}
{theyLikedMe && (
<div style={styles.interactionBadge}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '14px', height: '14px' }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
Likes you
</div>
)}
{/* Scroll hint */}
<div style={styles.scrollHint}>
<span>Scroll for more</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<style>{`@keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(5px); } 60% { transform: translateY(3px); } }`}</style>
</div>
</div>
{/* Content */}
<div style={styles.content}>
{/* Back button when viewing partner */}
{profileHistory.length > 0 && (
<button
style={styles.backButton}
onClick={handleGoBack}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)';
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
Back to previous profile
</button>
)}
{/* Bio */}
{profile.bio && (
<div style={styles.bioSection}>
<p style={styles.bioText}>{profile.bio}</p>
</div>
)}
{/* Hidden Bio */}
{profile.hasHiddenBio && profile.hiddenBio && (
<div style={{ ...styles.section, padding: '16px', background: 'rgba(190,49,68,0.1)', borderRadius: '16px', border: '1px solid rgba(190,49,68,0.2)' }}>
<p style={styles.sectionLabel}>Private Note</p>
<p style={{ ...styles.bioText, fontSize: '14px' }}>{profile.hiddenBio}</p>
</div>
)}
{/* Connection Goals */}
{profile.connectionGoals?.length > 0 && (
<div style={styles.section}>
<p style={styles.sectionLabel}>Looking For</p>
<div style={styles.tagsContainer}>
{profile.connectionGoals.filter((g: any) => typeof g === 'string').map((goal: string) => (
<span key={goal} style={styles.tag('primary')}>
{goal.replace(/_/g, ' ')}
</span>
))}
</div>
</div>
)}
{/* Desires */}
{profile.desires?.length > 0 && (
<div style={styles.section}>
<p style={styles.sectionLabel}>Desires</p>
<div style={styles.tagsContainer}>
{profile.desires.filter((d: any) => typeof d === 'string').map((desire: string) => (
<span key={desire} style={styles.tag('default')}>
{desire.replace(/_/g, ' ')}
</span>
))}
</div>
</div>
)}
{/* Interests */}
{profile.interests?.length > 0 && (
<div style={styles.section}>
<p style={styles.sectionLabel}>Interests</p>
<div style={styles.tagsContainer}>
{profile.interests.filter((i: any) => typeof i === 'string').map((interest: string) => (
<span key={interest} style={styles.tag('success')}>
{interest}
</span>
))}
</div>
</div>
)}
{/* Partner */}
{profile.constellation?.length > 0 && (
<div style={styles.section}>
<p style={styles.sectionLabel}>Partner</p>
{profile.constellation.map((partner: any) => (
<div
key={partner.partnerId}
style={styles.partnerCard}
onClick={() => partner.partnerProfile?.id && handleViewPartner(partner.partnerProfile.id)}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.06)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.03)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)';
}}
>
{partner.partnerProfile?.photos?.[0]?.pictureUrls && (
<ProxiedImage
src={partner.partnerProfile.photos[0].pictureUrls.medium || partner.partnerProfile.photos[0].pictureUrls.small}
alt={partner.partnerProfile.imaginaryName}
style={styles.partnerPhoto}
/>
)}
<div>
<p style={styles.partnerName}>{partner.partnerProfile?.imaginaryName}</p>
{partner.partnerLabel && typeof partner.partnerLabel === 'string' && (
<p style={styles.partnerLabel}>{partner.partnerLabel}</p>
)}
</div>
<div style={styles.partnerChevron}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '20px', height: '20px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
</div>
))}
</div>
)}
{/* Last seen */}
{profile.lastSeen && (
<p style={{ ...styles.sectionLabel, marginTop: '16px', marginBottom: 0 }}>
Last active {new Date(profile.lastSeen).toLocaleDateString()}
</p>
)}
</div>
</div>
{/* Action Bar */}
<div style={styles.actionBar}>
<button
style={{
...styles.actionButton('secondary'),
opacity: disliking ? 0.7 : 1,
}}
onClick={() => dislikeProfile()}
disabled={disliking}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '20px', height: '20px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{disliking ? 'Passing...' : 'Pass'}
</button>
{/* Ping button */}
<button
style={{
...styles.actionButton('ping'),
opacity: availablePings > 0 ? 1 : 0.5,
}}
onClick={() => availablePings > 0 && setShowPingModal(true)}
title={availablePings > 0 ? `Send a ping (${availablePings} available)` : 'No pings available'}
onMouseEnter={(e) => {
if (availablePings > 0) e.currentTarget.style.background = 'rgba(245,158,11,0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(245,158,11,0.15)';
}}
>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '20px', height: '20px' }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
<button
style={{
...styles.actionButton('primary'),
opacity: liking ? 0.7 : 1,
}}
onClick={() => likeProfile()}
disabled={liking}
onMouseEnter={(e) => {
if (!liking) e.currentTarget.style.transform = 'scale(1.02)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '20px', height: '20px' }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
{liking ? 'Liking...' : 'Like'}
</button>
</div>
{/* Ping Modal */}
{showPingModal && (
<PingModal
profileName={profile.imaginaryName}
availablePings={availablePings}
sending={sendingPing}
onSend={handleSendPing}
onClose={() => setShowPingModal(false)}
/>
)}
</>
) : null}
</div>
</div>
);
}

116
web/src/components/ui/Avatar.tsx Executable file
View File

@@ -0,0 +1,116 @@
import { useState } from 'react';
interface AvatarProps {
src?: string | null;
alt?: string;
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
className?: string;
ring?: boolean;
status?: 'online' | 'away' | 'offline';
}
export function Avatar({
src,
alt = 'Avatar',
size = 'md',
className = '',
ring = false,
status,
}: AvatarProps) {
const [imageError, setImageError] = useState(false);
const sizes = {
sm: 'w-8 h-8',
md: 'w-12 h-12',
lg: 'w-16 h-16',
xl: 'w-24 h-24',
'2xl': 'w-32 h-32',
};
const statusSizes = {
sm: 'w-2 h-2 bottom-0 right-0',
md: 'w-3 h-3 bottom-0 right-0',
lg: 'w-3.5 h-3.5 bottom-0.5 right-0.5',
xl: 'w-4 h-4 bottom-1 right-1',
'2xl': 'w-5 h-5 bottom-1.5 right-1.5',
};
const statusColors = {
online: 'bg-[var(--color-liked)]',
away: 'bg-amber-500',
offline: 'bg-[var(--color-text-muted)]',
};
const ringStyles = ring
? 'ring-2 ring-[var(--color-desire)] ring-offset-2 ring-offset-[var(--color-void)]'
: '';
if (!src || imageError) {
return (
<div className={`relative ${className}`}>
<div
className={`
${sizes[size]}
rounded-full
bg-gradient-to-br from-[var(--color-surface-elevated)] to-[var(--color-surface)]
border border-[var(--glass-border)]
flex items-center justify-center
${ringStyles}
`}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="w-1/2 h-1/2 text-[var(--color-text-muted)]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
</div>
{status && (
<span
className={`
absolute ${statusSizes[size]}
${statusColors[status]}
rounded-full
border-2 border-[var(--color-void)]
`}
/>
)}
</div>
);
}
return (
<div className={`relative ${className}`}>
<img
src={src}
alt={alt}
onError={() => setImageError(true)}
className={`
${sizes[size]}
rounded-full object-cover
bg-[var(--color-surface)]
${ringStyles}
`}
/>
{status && (
<span
className={`
absolute ${statusSizes[size]}
${statusColors[status]}
rounded-full
border-2 border-[var(--color-void)]
`}
/>
)}
</div>
);
}

71
web/src/components/ui/Badge.tsx Executable file
View File

@@ -0,0 +1,71 @@
import { ReactNode } from 'react';
interface BadgeProps {
children: ReactNode;
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline';
size?: 'sm' | 'md';
icon?: ReactNode;
className?: string;
}
export function Badge({
children,
variant = 'default',
size = 'sm',
icon,
className = '',
}: BadgeProps) {
const variants = {
default: `
bg-[var(--color-surface-elevated)]
text-[var(--color-text-secondary)]
border border-[var(--glass-border)]
`,
primary: `
bg-[var(--color-desire)]/15
text-[var(--color-desire-glow)]
border border-[var(--color-desire)]/20
`,
success: `
bg-[var(--color-liked)]/15
text-[var(--color-liked)]
border border-[var(--color-liked)]/20
`,
warning: `
bg-amber-500/15
text-amber-400
border border-amber-500/20
`,
danger: `
bg-[var(--color-passed)]/15
text-[var(--color-passed)]
border border-[var(--color-passed)]/20
`,
outline: `
bg-transparent
text-[var(--color-text-secondary)]
border border-[var(--color-text-muted)]/30
`,
};
const sizes = {
sm: 'px-2.5 py-1 text-xs',
md: 'px-3 py-1.5 text-sm',
};
return (
<span
className={`
inline-flex items-center gap-1.5
rounded-full font-medium
whitespace-nowrap
${variants[variant]}
${sizes[size]}
${className}
`}
>
{icon && <span className="flex-shrink-0">{icon}</span>}
{children}
</span>
);
}

104
web/src/components/ui/Button.tsx Executable file
View File

@@ -0,0 +1,104 @@
import { ReactNode, ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
icon?: ReactNode;
loading?: boolean;
}
export function Button({
children,
variant = 'primary',
size = 'md',
icon,
loading = false,
className = '',
disabled,
...props
}: ButtonProps) {
const baseStyles = `
relative inline-flex items-center justify-center gap-2
font-medium rounded-xl
transition-all duration-300 ease-out
disabled:opacity-40 disabled:cursor-not-allowed disabled:transform-none
active:scale-[0.98]
overflow-hidden
`;
const variants = {
primary: `
bg-gradient-to-r from-[var(--color-desire)] to-[var(--color-desire-glow)]
text-white
shadow-lg shadow-[var(--color-desire)]/25
hover:shadow-xl hover:shadow-[var(--color-desire)]/30
hover:brightness-110
before:absolute before:inset-0 before:bg-white/10 before:opacity-0 before:transition-opacity
hover:before:opacity-100
`,
secondary: `
bg-[var(--color-surface-elevated)]
text-white
border border-[var(--glass-border)]
hover:bg-[var(--color-surface-hover)]
hover:border-[var(--color-text-muted)]/30
`,
ghost: `
bg-transparent
text-[var(--color-text-secondary)]
hover:text-white
hover:bg-[var(--color-surface-elevated)]
`,
danger: `
bg-[var(--color-passed)]/10
text-[var(--color-passed)]
border border-[var(--color-passed)]/30
hover:bg-[var(--color-passed)]/20
`,
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-5 py-2.5 text-sm',
lg: 'px-7 py-3.5 text-base',
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading ? (
<>
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Loading...</span>
</>
) : (
<>
{icon && <span className="flex-shrink-0">{icon}</span>}
{children}
</>
)}
</button>
);
}

68
web/src/components/ui/Card.tsx Executable file
View File

@@ -0,0 +1,68 @@
import { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
variant?: 'default' | 'elevated' | 'glass';
padding?: 'none' | 'sm' | 'md' | 'lg';
hover?: boolean;
}
export function Card({
children,
className = '',
onClick,
variant = 'default',
padding = 'none',
hover = true,
}: CardProps) {
const variants = {
default: `
bg-[var(--color-surface)]
border border-[var(--glass-border)]
`,
elevated: `
bg-[var(--color-surface-elevated)]
border border-[var(--glass-border)]
shadow-lg shadow-black/20
`,
glass: `
glass
`,
};
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
const interactiveStyles = onClick && hover
? `
cursor-pointer
transition-all duration-300 ease-out
hover:bg-[var(--color-surface-elevated)]
hover:border-[var(--color-text-muted)]/20
hover:shadow-lg hover:shadow-black/30
hover:-translate-y-0.5
active:translate-y-0
`
: '';
return (
<div
onClick={onClick}
className={`
rounded-2xl overflow-hidden
${variants[variant]}
${paddings[padding]}
${interactiveStyles}
${className}
`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,85 @@
interface LoadingProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function Loading({ size = 'md', className = '' }: LoadingProps) {
const sizes = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<div className={`flex items-center justify-center ${className}`}>
<div className={`relative ${sizes[size]}`}>
{/* Outer ring */}
<div
className={`
absolute inset-0
border-2 border-[var(--color-surface-elevated)]
rounded-full
`}
/>
{/* Spinning gradient arc */}
<div
className={`
absolute inset-0
border-2 border-transparent
border-t-[var(--color-desire)]
border-r-[var(--color-desire-glow)]
rounded-full
animate-spin
`}
style={{ animationDuration: '0.8s' }}
/>
{/* Inner glow */}
<div
className={`
absolute inset-1
bg-[var(--color-desire)]/5
rounded-full
blur-sm
`}
/>
</div>
</div>
);
}
export function LoadingPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<Loading size="lg" />
<p className="text-[var(--color-text-muted)] text-sm animate-pulse">
Loading...
</p>
</div>
);
}
export function LoadingCard() {
return (
<div className="rounded-2xl overflow-hidden bg-[var(--color-surface)] border border-[var(--glass-border)]">
<div className="aspect-[3/4] shimmer" />
<div className="p-4 space-y-3">
<div className="h-6 w-3/4 rounded-lg shimmer" />
<div className="h-4 w-1/2 rounded-lg shimmer" />
<div className="flex gap-2">
<div className="h-6 w-16 rounded-full shimmer" />
<div className="h-6 w-20 rounded-full shimmer" />
</div>
</div>
</div>
);
}
export function LoadingCards({ count = 8 }: { count?: number }) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: count }).map((_, i) => (
<LoadingCard key={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState, useEffect } from 'react';
import { getProxiedImageUrl } from '../../utils/images';
interface ProxiedImageProps {
src: string | undefined | null;
alt: string;
className?: string;
style?: React.CSSProperties;
fallback?: React.ReactNode;
onError?: () => void;
onLoad?: () => void;
}
export function ProxiedImage({ src, alt, className, style, fallback, onError, onLoad }: ProxiedImageProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!src) {
setLoading(false);
onError?.();
return;
}
const proxiedUrl = getProxiedImageUrl(src);
if (!proxiedUrl) {
setLoading(false);
onError?.();
return;
}
let cancelled = false;
fetch(proxiedUrl)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then(blob => {
if (!cancelled) {
const url = URL.createObjectURL(blob);
setBlobUrl(url);
setLoading(false);
onLoad?.();
}
})
.catch(err => {
console.error('Image load failed:', proxiedUrl, err);
if (!cancelled) {
setError(true);
setLoading(false);
onError?.();
}
});
return () => {
cancelled = true;
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [src]);
if (loading) {
return (
<div className={`${className} bg-[var(--color-surface-light)] animate-pulse`} />
);
}
if (error || !blobUrl) {
return fallback ? <>{fallback}</> : (
<div className={`${className} bg-[var(--color-surface-light)] flex items-center justify-center`}>
<span className="text-4xl opacity-30">👤</span>
</div>
);
}
return (
<img
src={blobUrl}
alt={alt}
className={className}
style={style}
/>
);
}

61
web/src/config/constants.ts Executable file
View File

@@ -0,0 +1,61 @@
// ⚠️ When updating, also update vite.config.ts proxy headers (search for APP_VERSION)
export const APP_VERSION = '8.8.3';
export const OS_VERSION = '18.6.2';
export const API_CONFIG = {
// Use Vite proxy to bypass CORS
GRAPHQL_ENDPOINT: '/api/graphql',
FIREBASE_TOKEN_URL: '/api/firebase/v1/token',
FIREBASE_API_KEY: 'AIzaSyD9o9mzulN50-hqOwF6ww9pxUNUxwVOCXA',
// Backend server for data persistence
BACKEND_URL: 'http://localhost:3001',
};
// Exact headers from Proxyman capture
export const REQUEST_HEADERS = {
'x-device-os': 'ios',
'x-app-version': APP_VERSION,
'x-os-version': OS_VERSION,
'User-Agent': 'feeld-mobile',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Content-Type': 'application/json',
'Connection': 'keep-alive',
};
// Default credentials - can be overridden via localStorage
const DEFAULT_CREDENTIALS = {
PROFILE_ID: 'profile#c5f40eca-b972-41b8-ba74-24db33a8f244',
REFRESH_TOKEN: 'AMf-vBzSZvWXvunGC0c3Sn5Sf4vxwSdb2z4VwT6rLC7LhGo_I4Wd759NCtKBBmIM994RPlEd3eHFT7v0CwkTFpu7uTT80uFa7EDR5DgYK-DiCm4S8nvcuoPMgPK1pa2xRYafqXnUmMEaZNIpWozvQrqmGZHa62pFySbX6EXxXd-S9vewsbWbSXUkZtZUMtOx3GR5nP1jP-z_bTGFktrJUNjV0tfeGa6C3Jc5MOg4dvjPGJ6YAMKGGMkKoDauNK1lHFKSvtnfMRFC6KrJYar1bsCljre-9aaYwq2gJ9YSZ53T6JIkV2_74c0',
EVENT_ANALYTICS_ID: '53923e7d-39bb-42ac-99c6-99cdf6008d11',
};
// Auth credentials manager with localStorage persistence
export const getCredentials = () => {
if (typeof window === 'undefined') return DEFAULT_CREDENTIALS;
return {
PROFILE_ID: localStorage.getItem('feeld_profile_id') || DEFAULT_CREDENTIALS.PROFILE_ID,
REFRESH_TOKEN: localStorage.getItem('feeld_refresh_token') || DEFAULT_CREDENTIALS.REFRESH_TOKEN,
EVENT_ANALYTICS_ID: localStorage.getItem('feeld_analytics_id') || DEFAULT_CREDENTIALS.EVENT_ANALYTICS_ID,
};
};
export const setCredentials = (creds: { profileId?: string; refreshToken?: string; analyticsId?: string }) => {
if (creds.profileId) localStorage.setItem('feeld_profile_id', creds.profileId);
if (creds.refreshToken) localStorage.setItem('feeld_refresh_token', creds.refreshToken);
if (creds.analyticsId) localStorage.setItem('feeld_analytics_id', creds.analyticsId);
};
export const clearCredentials = () => {
localStorage.removeItem('feeld_profile_id');
localStorage.removeItem('feeld_refresh_token');
localStorage.removeItem('feeld_analytics_id');
};
// For backward compatibility - returns current credentials
export const TEST_CREDENTIALS = {
get PROFILE_ID() { return getCredentials().PROFILE_ID; },
get REFRESH_TOKEN() { return getCredentials().REFRESH_TOKEN; },
get EVENT_ANALYTICS_ID() { return getCredentials().EVENT_ANALYTICS_ID; },
};

View File

@@ -0,0 +1,211 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { StreamChat } from 'stream-chat';
import { useQuery } from '@apollo/client/react';
import { STREAM_CREDENTIALS_QUERY } from '../api/operations/queries';
import { TEST_CREDENTIALS } from '../config/constants';
// Stream Chat API Key for Feeld - this is a public key
// Found from app traffic analysis (chat.stream-io-api.com requests)
const STREAM_API_KEY = 'y4tp4akjeb49';
// Message type from Stream Chat
export interface StreamMessage {
id: string;
text?: string;
user?: {
id: string;
name?: string;
image?: string;
};
created_at?: string;
type?: string;
attachments?: Array<{
type?: string;
image_url?: string;
thumb_url?: string;
asset_url?: string;
}>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type StreamClient = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type StreamChannel = any;
interface StreamChatContextType {
client: StreamClient | null;
isConnected: boolean;
isConnecting: boolean;
error: string | null;
userId: string | null;
userName: string | null;
}
const StreamChatContext = createContext<StreamChatContextType>({
client: null,
isConnected: false,
isConnecting: true,
error: null,
userId: null,
userName: null,
});
export function StreamChatProvider({ children }: { children: ReactNode }) {
const [client, setClient] = useState<StreamClient | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const { data, loading, error: queryError } = useQuery(STREAM_CREDENTIALS_QUERY, {
variables: { profileId: TEST_CREDENTIALS.PROFILE_ID },
});
// Extract stable values to avoid reconnecting on every Apollo cache update
const streamToken = data?.profile?.streamToken;
const streamUserId = data?.profile?.streamUserId;
const streamUserName = data?.profile?.imaginaryName;
useEffect(() => {
if (loading) return;
if (queryError) {
setError(`Failed to get chat credentials: ${queryError.message}`);
setIsConnecting(false);
return;
}
if (!streamToken || !streamUserId) {
setError('Stream credentials not available');
setIsConnecting(false);
return;
}
let cancelled = false;
const initClient = async () => {
try {
setIsConnecting(true);
setError(null);
const chatClient = StreamChat.getInstance(STREAM_API_KEY);
await chatClient.connectUser(
{
id: streamUserId,
name: streamUserName || 'User',
},
streamToken
);
if (cancelled) {
chatClient.disconnectUser().catch(console.error);
return;
}
setClient(chatClient);
setUserId(streamUserId);
setUserName(streamUserName);
setIsConnected(true);
setIsConnecting(false);
} catch (err) {
if (cancelled) return;
console.error('Stream Chat connection error:', err);
setError(err instanceof Error ? err.message : 'Failed to connect to chat');
setIsConnecting(false);
}
};
initClient();
return () => {
cancelled = true;
const chatClient = StreamChat.getInstance(STREAM_API_KEY);
chatClient.disconnectUser().catch(console.error);
};
}, [loading, queryError, streamToken, streamUserId, streamUserName]);
return (
<StreamChatContext.Provider value={{ client, isConnected, isConnecting, error, userId, userName }}>
{children}
</StreamChatContext.Provider>
);
}
export function useStreamChat() {
return useContext(StreamChatContext);
}
// Hook to get a specific channel
export function useChannel(channelId: string) {
const { client, isConnected } = useStreamChat();
const [channel, setChannel] = useState<StreamChannel | null>(null);
const [messages, setMessages] = useState<StreamMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!client || !isConnected || !channelId) {
setLoading(false);
return;
}
const initChannel = async () => {
try {
setLoading(true);
setError(null);
// Get or create the channel
const ch = client.channel('messaging', channelId);
await ch.watch();
setChannel(ch);
setMessages(ch.state.messages);
setLoading(false);
// Listen for new messages
const handleNewMessage = (event: { message?: StreamMessage }) => {
if (event.message) {
setMessages(prev => [...prev, event.message as StreamMessage]);
}
};
ch.on('message.new', handleNewMessage);
return () => {
ch.off('message.new', handleNewMessage);
};
} catch (err) {
console.error('Channel init error:', err);
setError(err instanceof Error ? err.message : 'Failed to load channel');
setLoading(false);
}
};
initChannel();
}, [client, isConnected, channelId]);
const sendMessage = useCallback(async (text: string) => {
if (!channel) return;
try {
await channel.sendMessage({ text });
} catch (err) {
console.error('Send message error:', err);
throw err;
}
}, [channel]);
const loadMoreMessages = useCallback(async () => {
if (!channel) return;
try {
const response = await channel.query({
messages: { limit: 30, id_lt: messages[0]?.id },
});
setMessages(prev => [...(response.messages || []), ...prev]);
} catch (err) {
console.error('Load more messages error:', err);
}
}, [channel, messages]);
return { channel, messages, loading, error, sendMessage, loadMoreMessages };
}

100
web/src/hooks/useAuth.tsx Executable file
View File

@@ -0,0 +1,100 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
const TOKEN_KEY = 'feeld_auth_token';
// API URL for auth endpoints
const AUTH_API = window.location.port === '5173' || window.location.port === '3000'
? 'http://localhost:3001/api/auth'
: '/api/auth';
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Check if already authenticated on mount
useEffect(() => {
const checkAuth = async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) {
setIsLoading(false);
return;
}
try {
const response = await fetch(`${AUTH_API}/verify`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
setIsAuthenticated(true);
} else {
localStorage.removeItem(TOKEN_KEY);
}
} catch (e) {
// Server not available, allow access if token exists (offline mode)
setIsAuthenticated(true);
}
setIsLoading(false);
};
checkAuth();
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch(`${AUTH_API}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem(TOKEN_KEY, data.token);
setIsAuthenticated(true);
return true;
}
return false;
} catch (e) {
console.error('Login failed:', e);
return false;
}
};
const logout = () => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
fetch(`${AUTH_API}/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(err => console.error('Logout request failed:', err));
}
localStorage.removeItem(TOKEN_KEY);
setIsAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,121 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation } from '@apollo/client/react';
import { PROFILE_DISLIKE_MUTATION } from '../api/operations/mutations';
import * as dataSync from '../api/dataSync';
export interface DislikedProfile {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
photos?: any[];
dislikedAt: string;
}
export function useDislikedProfiles() {
const [dislikedProfiles, setDislikedProfiles] = useState<DislikedProfile[]>([]);
const [loading, setLoading] = useState(true);
const [profileDislike] = useMutation(PROFILE_DISLIKE_MUTATION);
// Load from storage on mount
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const profiles = await dataSync.getDislikedProfiles();
setDislikedProfiles(profiles);
} catch (e) {
console.error('Failed to load disliked profiles:', e);
// Fall back to localStorage
const stored = localStorage.getItem('feeld_disliked_profiles');
if (stored) {
try {
setDislikedProfiles(JSON.parse(stored));
} catch (parseError) {
console.error('Failed to parse disliked profiles:', parseError);
}
}
} finally {
setLoading(false);
}
};
load();
}, []);
const dislikeProfile = useCallback(async (profile: {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
photos?: any[];
}) => {
// Don't dislike duplicates
if (dislikedProfiles.some(p => p.id === profile.id)) {
console.log('Profile already disliked:', profile.imaginaryName);
return { success: true, alreadyDisliked: true };
}
try {
// Call the GraphQL mutation
const result = await profileDislike({
variables: { targetProfileId: profile.id },
});
console.log('Dislike result:', result.data?.profileDislike);
if (result.data?.profileDislike === 'SENT') {
const newProfile: DislikedProfile = {
...profile,
dislikedAt: new Date().toISOString(),
};
// Optimistic update
setDislikedProfiles(prev => [newProfile, ...prev]);
// Sync to storage
await dataSync.addDislikedProfile(profile);
return { success: true };
}
return { success: false, error: 'Dislike not sent' };
} catch (error) {
console.error('Failed to dislike profile:', error);
return { success: false, error };
}
}, [dislikedProfiles, profileDislike]);
const removeDislikedProfile = useCallback(async (id: string) => {
// Optimistic update
setDislikedProfiles(prev => prev.filter(p => p.id !== id));
// Sync to storage
await dataSync.removeDislikedProfile(id);
}, []);
const clearAllDisliked = useCallback(async () => {
setDislikedProfiles([]);
await dataSync.clearAllDislikedProfiles();
}, []);
const isDisliked = useCallback((id: string) => {
return dislikedProfiles.some(p => p.id === id);
}, [dislikedProfiles]);
const getDislikedProfileIds = useCallback(() => {
return dislikedProfiles.map(p => p.id);
}, [dislikedProfiles]);
return {
dislikedProfiles,
loading,
dislikeProfile,
removeDislikedProfile,
clearAllDisliked,
isDisliked,
getDislikedProfileIds,
};
}

View File

@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import * as dataSync from '../api/dataSync';
const STORAGE_KEY = 'feeld_liked_profiles';
export interface LikedProfile {
id: string;
name?: string;
likedAt: number;
}
export function useLikedProfiles() {
const [likedProfiles, setLikedProfiles] = useState<LikedProfile[]>([]);
const [loading, setLoading] = useState(true);
// Load from storage on mount (tries server first, falls back to localStorage)
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const profiles = await dataSync.getLikedProfiles();
setLikedProfiles(profiles);
} catch (e) {
console.error('Failed to load liked profiles:', e);
// Fall back to localStorage
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
setLikedProfiles(JSON.parse(stored));
} catch (e) {
console.error('Failed to parse liked profiles:', e);
}
}
} finally {
setLoading(false);
}
};
load();
}, []);
const addLikedProfile = async (id: string, name?: string) => {
// Don't add duplicates
if (likedProfiles.some(p => p.id === id)) return;
const newProfile: LikedProfile = {
id,
name,
likedAt: Date.now(),
};
// Optimistic update
setLikedProfiles(prev => [newProfile, ...prev]);
// Sync to storage
await dataSync.addLikedProfile(id, name);
};
const removeLikedProfile = async (id: string) => {
// Optimistic update
setLikedProfiles(prev => prev.filter(p => p.id !== id));
// Sync to storage
await dataSync.removeLikedProfile(id);
};
const clearAllLiked = async () => {
setLikedProfiles([]);
await dataSync.setData('liked_profiles', []);
};
const isLiked = (id: string) => {
return likedProfiles.some(p => p.id === id);
};
const getLikedProfileIds = () => {
return likedProfiles.map(p => p.id);
};
return {
likedProfiles,
loading,
addLikedProfile,
removeLikedProfile,
clearAllLiked,
isLiked,
getLikedProfileIds,
};
}
// Standalone function for use outside React components
export async function addLikedProfileToStorage(id: string, name?: string) {
await dataSync.addLikedProfile(id, name);
}

199
web/src/hooks/useLocation.tsx Executable file
View File

@@ -0,0 +1,199 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import * as dataSync from '../api/dataSync';
// UUID generator that works in non-secure contexts (HTTP)
function generateUUID(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export interface SavedLocation {
id: string;
name: string;
latitude: number;
longitude: number;
}
export interface LocationState {
latitude: number;
longitude: number;
name?: string;
}
interface LocationContextType {
location: LocationState | null;
savedLocations: SavedLocation[];
setLocation: (location: LocationState | null) => void;
saveLocation: (name: string, lat: number, lng: number) => void;
deleteLocation: (id: string) => void;
clearLocation: () => void;
}
const LocationContext = createContext<LocationContextType | null>(null);
const STORAGE_KEY = 'feeld_locations';
const CURRENT_LOCATION_KEY = 'feeld_current_location';
export function LocationProvider({ children }: { children: ReactNode }) {
const [location, setLocationState] = useState<LocationState | null>(() => {
const saved = localStorage.getItem(CURRENT_LOCATION_KEY);
return saved ? JSON.parse(saved) : null;
});
const [savedLocations, setSavedLocations] = useState<SavedLocation[]>(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
});
const [hasSyncedFromServer, setHasSyncedFromServer] = useState(false);
// Fetch from server on mount and merge with localStorage
useEffect(() => {
const syncFromServer = async () => {
try {
const serverData = await dataSync.getAllFromServer();
if (serverData) {
// Merge saved locations from server
if (serverData.savedLocations && serverData.savedLocations.length > 0) {
setSavedLocations(prev => {
const merged = [...serverData.savedLocations];
for (const local of prev) {
if (!merged.some((s: SavedLocation) => s.id === local.id)) {
merged.push(local);
}
}
// Update localStorage with merged data
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
return merged;
});
}
// Restore current location if we don't have one
if (serverData.currentLocation && !location) {
setLocationState(serverData.currentLocation);
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.currentLocation));
}
// Restore custom location if we don't have one
if (serverData.customLocation && !location) {
setLocationState(serverData.customLocation);
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.customLocation));
}
}
} catch (e) {
console.error('Failed to sync locations from server:', e);
} finally {
setHasSyncedFromServer(true);
}
};
syncFromServer();
}, []);
useEffect(() => {
if (location) {
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(location));
dataSync.setData('currentLocation', location);
} else {
localStorage.removeItem(CURRENT_LOCATION_KEY);
}
}, [location]);
// Only sync to server after initial load from server is complete
useEffect(() => {
if (!hasSyncedFromServer) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(savedLocations));
// Sync to server
dataSync.setData('savedLocations', savedLocations);
// Also sync to dedicated saved-locations endpoint for rotation cron
fetch('/api/saved-locations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: savedLocations }),
}).catch(() => {}); // Best-effort
}, [savedLocations, hasSyncedFromServer]);
const setLocation = (loc: LocationState | null) => {
setLocationState(loc);
// Sync to server
if (loc) {
dataSync.setData('customLocation', loc);
}
};
const saveLocation = (name: string, latitude: number, longitude: number) => {
const newLocation: SavedLocation = {
id: generateUUID(),
name,
latitude,
longitude,
};
setSavedLocations((prev) => [...prev, newLocation]);
};
const deleteLocation = (id: string) => {
setSavedLocations((prev) => prev.filter((loc) => loc.id !== id));
};
const clearLocation = () => {
setLocationState(null);
};
return (
<LocationContext.Provider
value={{
location,
savedLocations,
setLocation,
saveLocation,
deleteLocation,
clearLocation,
}}
>
{children}
</LocationContext.Provider>
);
}
export function useLocation() {
const context = useContext(LocationContext);
if (!context) {
throw new Error('useLocation must be used within a LocationProvider');
}
return context;
}
// Geocoding helper using OpenStreetMap Nominatim (free, no API key needed)
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number; displayName: string } | null> {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`,
{
headers: {
'User-Agent': 'FeeldWebApp/1.0',
},
}
);
const data = await response.json();
if (data.length > 0) {
return {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon),
displayName: data[0].display_name,
};
}
return null;
} catch (error) {
console.error('Geocoding error:', error);
return null;
}
}

237
web/src/hooks/useSentPings.ts Executable file
View File

@@ -0,0 +1,237 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQuery } from '@apollo/client/react';
import * as dataSync from '../api/dataSync';
import { PROFILE_PING_MUTATION } from '../api/operations/mutations';
import { ACCOUNT_STATUS_QUERY, PROFILE_QUERY } from '../api/operations/queries';
import { apolloClient } from '../api/client';
// Base sent ping type (from storage)
export type SentPing = {
targetProfileId: string;
targetName?: string;
message?: string;
sentAt: number;
status: 'SENT' | 'MATCHED' | 'EXPIRED';
};
// Profile data fetched from API
export type ProfileData = {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
bio?: string;
desires?: string[];
connectionGoals?: string[];
verificationStatus?: string;
isMajestic?: boolean;
distance?: { km: number; mi: number };
photos?: Array<{
id: string;
pictureUrls?: { small?: string; medium?: string; large?: string };
}>;
interactionStatus?: {
mine?: string;
theirs?: string;
message?: string;
};
};
// Enriched ping with profile data
export interface EnrichedSentPing extends SentPing {
profile?: ProfileData;
profileLoading?: boolean;
profileError?: string;
}
export function useSentPings() {
const [sentPings, setSentPings] = useState<SentPing[]>([]);
const [enrichedPings, setEnrichedPings] = useState<EnrichedSentPing[]>([]);
const [loading, setLoading] = useState(true);
const [profilesLoading, setProfilesLoading] = useState(false);
// Query for available pings count
const { data: accountData, refetch: refetchAccount } = useQuery(ACCOUNT_STATUS_QUERY, {
fetchPolicy: 'cache-and-network',
});
// Always report 2 pings available — bypass Feeld's client-side limit
const availablePings = Math.max(accountData?.account?.availablePings ?? 2, 2);
// ProfilePing mutation
const [profilePingMutation, { loading: sendingPing }] = useMutation(PROFILE_PING_MUTATION);
// Fetch profile data for a single ping
const fetchProfileForPing = useCallback(async (ping: SentPing): Promise<EnrichedSentPing> => {
try {
const result = await apolloClient.query({
query: PROFILE_QUERY,
variables: { profileId: ping.targetProfileId },
fetchPolicy: 'cache-first',
});
const profile = result.data?.profile;
if (profile) {
return {
...ping,
profile,
targetName: profile.imaginaryName || ping.targetName,
};
}
return { ...ping, profileError: 'Profile not found' };
} catch (e: any) {
console.error(`Failed to fetch profile ${ping.targetProfileId}:`, e);
return { ...ping, profileError: e.message || 'Failed to load profile' };
}
}, []);
// Load sent pings from storage on mount
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const pings = await dataSync.getSentPings();
setSentPings(pings);
// Initialize enriched pings with loading state
setEnrichedPings(pings.map(p => ({ ...p, profileLoading: true })));
} catch (e) {
console.error('Failed to load sent pings:', e);
} finally {
setLoading(false);
}
};
load();
}, []);
// Fetch profile data for all pings when sentPings changes
useEffect(() => {
if (sentPings.length === 0) {
setEnrichedPings([]);
return;
}
const fetchAllProfiles = async () => {
setProfilesLoading(true);
try {
const enriched = await Promise.all(
sentPings.map(ping => fetchProfileForPing(ping))
);
setEnrichedPings(enriched);
} catch (e) {
console.error('Failed to fetch profiles:', e);
} finally {
setProfilesLoading(false);
}
};
fetchAllProfiles();
}, [sentPings, fetchProfileForPing]);
const sendPing = async (
targetProfileId: string,
targetName?: string,
message?: string
): Promise<{ success: boolean; error?: string; status?: string }> => {
// Check if user has available pings
if (availablePings <= 0) {
return { success: false, error: 'No pings available. Purchase more pings to continue.' };
}
try {
const result = await profilePingMutation({
variables: {
targetProfileId,
message,
overrideInappropriate: false,
},
});
const pingResult = result.data?.profilePing;
if (pingResult?.status === 'SENT') {
// Save to local storage
await dataSync.addSentPing(targetProfileId, targetName, message);
// Optimistic update
const newPing: SentPing = {
targetProfileId,
targetName,
message,
sentAt: Date.now(),
status: 'SENT',
};
setSentPings(prev => [newPing, ...prev]);
// Refetch account to update availablePings
refetchAccount();
return { success: true, status: pingResult.status };
}
return {
success: false,
error: `Ping failed with status: ${pingResult?.status || 'unknown'}`,
status: pingResult?.status,
};
} catch (e: any) {
console.error('Failed to send ping:', e);
return { success: false, error: e.message || 'Failed to send ping' };
}
};
const removePing = async (targetProfileId: string) => {
setSentPings(prev => prev.filter(p => p.targetProfileId !== targetProfileId));
await dataSync.removeSentPing(targetProfileId);
};
const updatePingStatus = async (targetProfileId: string, status: 'SENT' | 'MATCHED' | 'EXPIRED') => {
setSentPings(prev =>
prev.map(p => (p.targetProfileId === targetProfileId ? { ...p, status } : p))
);
await dataSync.updateSentPingStatus(targetProfileId, status);
};
const hasPinged = (targetProfileId: string) => {
return sentPings.some(p => p.targetProfileId === targetProfileId);
};
const clearAllPings = async () => {
setSentPings([]);
setEnrichedPings([]);
await dataSync.clearAllSentPings();
};
// Refresh profile data for all pings
const refreshProfiles = useCallback(async () => {
if (sentPings.length === 0) return;
setProfilesLoading(true);
try {
const enriched = await Promise.all(
sentPings.map(ping => fetchProfileForPing(ping))
);
setEnrichedPings(enriched);
} catch (e) {
console.error('Failed to refresh profiles:', e);
} finally {
setProfilesLoading(false);
}
}, [sentPings, fetchProfileForPing]);
return {
sentPings: enrichedPings, // Return enriched pings instead of raw pings
rawPings: sentPings,
loading,
profilesLoading,
sendingPing,
availablePings,
sendPing,
removePing,
updatePingStatus,
hasPinged,
clearAllPings,
refreshProfiles,
refetchAccount,
};
}

339
web/src/index.css Executable file
View File

@@ -0,0 +1,339 @@
/* Import distinctive fonts - must be before tailwindcss */
@import url('https://api.fontshare.com/v2/css?f[]=clash-display@700,600,500&f[]=satoshi@400,500,700&display=swap');
@import "tailwindcss";
:root {
/* Navigation width for layout */
--nav-width: 0px;
/* Core palette - Midnight Velvet */
--color-void: #000000;
--color-midnight: #0a0a0f;
--color-deep: #12121a;
--color-surface: #1a1a24;
--color-surface-elevated: #24242f;
--color-surface-hover: #2e2e3a;
/* Accent colors */
--color-desire: #c41e3a;
--color-desire-glow: #e91e63;
--color-desire-soft: rgba(196, 30, 58, 0.15);
--color-rose-gold: #b76e79;
--color-champagne: #f7e7ce;
/* Status colors */
--color-liked: #22c55e;
--color-liked-soft: rgba(34, 197, 94, 0.15);
--color-passed: #ef4444;
--color-passed-soft: rgba(239, 68, 68, 0.15);
/* Text hierarchy */
--color-text: #ffffff;
--color-text-primary: #ffffff;
--color-text-secondary: #9ca3af;
--color-text-muted: #6b7280;
/* Gradients */
--gradient-desire: linear-gradient(135deg, #c41e3a 0%, #e91e63 100%);
--gradient-surface: linear-gradient(180deg, rgba(26, 26, 36, 0.8) 0%, rgba(10, 10, 15, 0.95) 100%);
--gradient-glass: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
--gradient-photo-overlay: linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.7) 70%, rgba(0, 0, 0, 0.95) 100%);
/* Effects */
--glow-desire: 0 0 30px rgba(196, 30, 58, 0.3);
--glow-subtle: 0 0 60px rgba(196, 30, 58, 0.1);
--shadow-elevated: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4);
/* Glass morphism */
--glass-bg: rgba(26, 26, 36, 0.7);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: blur(20px);
/* Typography */
--font-display: 'Clash Display', sans-serif;
--font-body: 'Satoshi', sans-serif;
/* Spacing rhythm */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* Border radius */
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.5rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-sensual: 600ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Desktop nav spacing */
@media (min-width: 768px) {
:root {
--nav-width: 104px;
}
}
/* Base reset and defaults */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-body);
font-weight: 400;
background-color: var(--color-void);
color: var(--color-text);
min-height: 100vh;
line-height: 1.6;
overflow-x: hidden;
}
/* Ambient background effect */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(196, 30, 58, 0.08) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(183, 110, 121, 0.05) 0%, transparent 50%);
pointer-events: none;
z-index: -1;
}
/* Subtle noise texture overlay */
body::after {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.015;
pointer-events: none;
z-index: 1000;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
position: relative;
}
/* Typography utilities */
.font-display {
font-family: var(--font-display);
}
.text-gradient {
background: var(--gradient-desire);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-elevated);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-surface-hover);
}
/* Selection styling */
::selection {
background: var(--color-desire);
color: white;
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--color-desire);
outline-offset: 2px;
}
/* Smooth page transitions */
.page-enter {
opacity: 0;
transform: translateY(10px);
}
.page-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity var(--transition-slow), transform var(--transition-slow);
}
/* Stagger animation utility */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: var(--glow-desire);
}
50% {
box-shadow: 0 0 40px rgba(196, 30, 58, 0.4);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-fade-up {
animation: fadeSlideUp var(--transition-sensual) both;
}
.animate-fade-in {
animation: fadeIn var(--transition-slow) both;
}
.animate-scale-in {
animation: scaleIn var(--transition-slow) both;
}
.animate-slide-right {
animation: slideInRight var(--transition-slow) both;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Stagger delays */
.stagger-1 { animation-delay: 50ms; }
.stagger-2 { animation-delay: 100ms; }
.stagger-3 { animation-delay: 150ms; }
.stagger-4 { animation-delay: 200ms; }
.stagger-5 { animation-delay: 250ms; }
.stagger-6 { animation-delay: 300ms; }
.stagger-7 { animation-delay: 350ms; }
.stagger-8 { animation-delay: 400ms; }
/* Glass morphism utility */
.glass {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
}
/* Shimmer loading effect */
.shimmer {
background: linear-gradient(
90deg,
var(--color-surface) 0%,
var(--color-surface-elevated) 50%,
var(--color-surface) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Image hover zoom effect */
.img-zoom {
transition: transform var(--transition-sensual);
}
.img-zoom:hover {
transform: scale(1.05);
}
/* Magnetic button effect placeholder */
.magnetic {
transition: transform var(--transition-fast);
}
/* Hide scrollbar utility */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}

10
web/src/main.tsx Executable file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

693
web/src/pages/ApiExplorer.tsx Executable file
View File

@@ -0,0 +1,693 @@
import { useState } from 'react';
import { apolloClient } from '../api/client';
import { EXPERIMENTAL_QUERIES, FILTERED_WHO_I_LIKED_MUTATION, INTERACTIONS_OUTGOING_QUERY, DIRECT_PROFILE_LOOKUP_QUERY } from '../api/operations/experimental';
import { WHO_LIKES_ME_QUERY, DISCOVER_PROFILES_QUERY } from '../api/operations/queries';
const styles = {
container: {
padding: '24px',
maxWidth: '1200px',
margin: '0 auto',
},
header: {
marginBottom: '32px',
},
title: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '32px',
fontWeight: 700,
color: '#ffffff',
margin: 0,
marginBottom: '8px',
},
subtitle: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
color: 'rgba(255,255,255,0.5)',
margin: 0,
},
section: {
marginBottom: '32px',
},
sectionTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '18px',
fontWeight: 600,
color: '#ffffff',
marginBottom: '16px',
},
buttonGrid: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '12px',
marginBottom: '24px',
},
button: (isLoading: boolean) => ({
padding: '12px 20px',
borderRadius: '8px',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: isLoading ? 'rgba(139,92,246,0.3)' : 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#ffffff',
cursor: isLoading ? 'wait' : 'pointer',
transition: 'all 0.2s ease',
}),
runAllButton: {
padding: '14px 28px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
border: 'none',
color: '#ffffff',
cursor: 'pointer',
marginBottom: '24px',
},
results: {
background: 'rgba(0,0,0,0.3)',
borderRadius: '12px',
padding: '20px',
maxHeight: '600px',
overflow: 'auto',
},
resultItem: (success: boolean) => ({
marginBottom: '16px',
padding: '16px',
borderRadius: '8px',
background: success ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)',
border: `1px solid ${success ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
}),
resultName: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
fontWeight: 600,
color: '#ffffff',
marginBottom: '8px',
},
resultStatus: (success: boolean) => ({
fontFamily: "'Satoshi', sans-serif",
fontSize: '12px',
fontWeight: 500,
color: success ? '#22c55e' : '#ef4444',
marginBottom: '8px',
}),
resultData: {
fontFamily: 'monospace',
fontSize: '11px',
color: 'rgba(255,255,255,0.7)',
whiteSpace: 'pre-wrap' as const,
wordBreak: 'break-all' as const,
maxHeight: '200px',
overflow: 'auto',
},
clearButton: {
padding: '10px 20px',
borderRadius: '8px',
fontSize: '13px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
background: 'transparent',
border: '1px solid rgba(255,255,255,0.2)',
color: 'rgba(255,255,255,0.7)',
cursor: 'pointer',
marginLeft: '12px',
},
};
interface QueryResult {
name: string;
success: boolean;
data?: any;
error?: string;
}
export function ApiExplorerPage() {
const [results, setResults] = useState<QueryResult[]>([]);
const [loading, setLoading] = useState<string | null>(null);
const [runningAll, setRunningAll] = useState(false);
const runQuery = async (name: string, query: any, variables: any = { sortBy: 'LAST_INTERACTION' }) => {
setLoading(name);
try {
const result = await apolloClient.query({
query,
variables,
fetchPolicy: 'no-cache',
errorPolicy: 'all',
});
const newResult: QueryResult = {
name,
success: !result.errors,
data: result.data,
error: result.errors ? result.errors.map((e: any) => e.message).join(', ') : undefined,
};
setResults(prev => [...prev.filter(r => r.name !== name), newResult]);
} catch (err: any) {
setResults(prev => [
...prev.filter(r => r.name !== name),
{ name, success: false, error: err.message },
]);
} finally {
setLoading(null);
}
};
const runMutation = async (name: string, mutation: any, variables: any) => {
setLoading(name);
try {
const result = await apolloClient.mutate({
mutation,
variables,
errorPolicy: 'all',
});
const newResult: QueryResult = {
name,
success: !result.errors,
data: result.data,
error: result.errors ? result.errors.map((e: any) => e.message).join(', ') : undefined,
};
setResults(prev => [...prev.filter(r => r.name !== name), newResult]);
} catch (err: any) {
setResults(prev => [
...prev.filter(r => r.name !== name),
{ name, success: false, error: err.message },
]);
} finally {
setLoading(null);
}
};
const runAllQueries = async () => {
setRunningAll(true);
setResults([]);
for (const { name, query } of EXPERIMENTAL_QUERIES) {
await runQuery(name, query);
// Small delay between requests
await new Promise(resolve => setTimeout(resolve, 500));
}
// Also try the mutation
await runMutation('filteredWhoILiked', FILTERED_WHO_I_LIKED_MUTATION, {
input: { filters: {}, sortBy: 'LAST_INTERACTION' },
});
// Try interactions with direction
await runQuery('interactions(direction: outgoing)', INTERACTIONS_OUTGOING_QUERY, {
sortBy: 'LAST_INTERACTION',
direction: 'OUTGOING',
});
setRunningAll(false);
};
// Test if direct profile lookup bypasses WhoLikesMe paywall
const testDirectProfileLookup = async () => {
setLoading('directLookup');
try {
// First, get WhoLikesMe to see the anonymized profiles
const likesResult = await apolloClient.query({
query: WHO_LIKES_ME_QUERY,
variables: { sortBy: 'LAST_INTERACTION' },
fetchPolicy: 'no-cache',
});
const anonymizedProfiles = likesResult.data?.interactions?.nodes || [];
if (anonymizedProfiles.length === 0) {
setResults(prev => [...prev, {
name: 'Direct Profile Lookup Test',
success: false,
error: 'No profiles in WhoLikesMe to test',
}]);
return;
}
// Analyze anonymized data first
const anonymizedAnalysis = anonymizedProfiles.slice(0, 5).map((p: any) => ({
id: p.id,
name: p.imaginaryName,
age: p.age,
gender: p.gender,
sexuality: p.sexuality,
photoUrl: p.photos?.[0]?.pictureUrl,
photoPublicId: p.photos?.[0]?.publicId,
}));
// Now try direct lookup for the first few profiles
const directLookups = await Promise.all(
anonymizedProfiles.slice(0, 3).map(async (profile: any) => {
try {
const result = await apolloClient.query({
query: DIRECT_PROFILE_LOOKUP_QUERY,
variables: { profileId: profile.id },
fetchPolicy: 'no-cache',
});
return {
id: profile.id,
fromWhoLikesMe: {
name: profile.imaginaryName,
age: profile.age,
gender: profile.gender,
sexuality: profile.sexuality,
photoUrl: profile.photos?.[0]?.pictureUrl,
},
fromDirectLookup: result.data?.profile ? {
name: result.data.profile.imaginaryName,
age: result.data.profile.age,
gender: result.data.profile.gender,
sexuality: result.data.profile.sexuality,
photoUrl: result.data.profile.photos?.[0]?.pictureUrl,
photoUrls: result.data.profile.photos?.[0]?.pictureUrls,
} : null,
error: null,
};
} catch (err: any) {
return {
id: profile.id,
fromWhoLikesMe: { name: profile.imaginaryName },
fromDirectLookup: null,
error: err.message,
};
}
})
);
// Check if all anonymized profiles have same metadata (paywall detection)
const uniqueAges = new Set(anonymizedProfiles.map((p: any) => p.age));
const uniqueGenders = new Set(anonymizedProfiles.map((p: any) => p.gender));
const uniqueSexualities = new Set(anonymizedProfiles.map((p: any) => p.sexuality));
setResults(prev => [...prev, {
name: 'WhoLikesMe Paywall Analysis',
success: true,
data: {
totalProfiles: anonymizedProfiles.length,
paywallDetection: {
allSameAge: uniqueAges.size === 1 ? `YES - all are ${[...uniqueAges][0]}` : `No - ${uniqueAges.size} unique ages`,
allSameGender: uniqueGenders.size === 1 ? `YES - all are ${[...uniqueGenders][0]}` : `No - ${uniqueGenders.size} unique genders`,
allSameSexuality: uniqueSexualities.size === 1 ? `YES - all are ${[...uniqueSexualities][0]}` : `No - ${uniqueSexualities.size} unique sexualities`,
},
anonymizedProfiles: anonymizedAnalysis,
directLookupResults: directLookups,
conclusion: directLookups.some(l => l.fromDirectLookup && l.fromDirectLookup.photoUrl !== 'HIDDEN')
? '🎉 BYPASS FOUND! Direct lookup returns real data!'
: '🔒 No bypass - direct lookup also returns anonymized/blocked data',
},
}]);
} catch (err: any) {
setResults(prev => [...prev, {
name: 'Direct Profile Lookup Test',
success: false,
error: err.message,
}]);
} finally {
setLoading(null);
}
};
// Cross-reference WhoLikesMe with Discover profiles to find matches
const crossReferenceProfiles = async () => {
setLoading('crossReference');
try {
// Fetch WhoLikesMe first
const likesResult = await apolloClient.query({
query: WHO_LIKES_ME_QUERY,
variables: { sortBy: 'LAST_INTERACTION' },
fetchPolicy: 'no-cache',
});
const whoLikesMeProfiles = likesResult.data?.interactions?.nodes || [];
// Fetch discover profiles multiple times (4 batches)
const allDiscoverProfiles: any[] = [];
const batchCount = 4;
for (let i = 0; i < batchCount; i++) {
const discoverResult = await apolloClient.query({
query: DISCOVER_PROFILES_QUERY,
variables: {
input: {
filters: {
ageRange: [18, 99],
maxDistance: 100,
lookingFor: ['WOMAN', 'MAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE', 'MAN_MAN_COUPLE', 'NON_BINARY'],
recentlyOnline: false,
desiringFor: [],
},
},
},
fetchPolicy: 'no-cache',
});
const profiles = discoverResult.data?.discovery?.nodes || [];
allDiscoverProfiles.push(...profiles);
// Small delay between requests
if (i < batchCount - 1) {
await new Promise(resolve => setTimeout(resolve, 300));
}
}
// Deduplicate by ID
const uniqueDiscoverProfiles = Array.from(
new Map(allDiscoverProfiles.map(p => [p.id, p])).values()
);
// Get names from WhoLikesMe
const whoLikesMeNames = whoLikesMeProfiles.map((p: any) => p.imaginaryName.toLowerCase());
// Find matches by name
const matches: any[] = [];
for (const discoverProfile of uniqueDiscoverProfiles) {
const name = discoverProfile.imaginaryName?.toLowerCase();
if (whoLikesMeNames.includes(name)) {
// Find the corresponding WhoLikesMe profile
const whoLikesMeMatch = whoLikesMeProfiles.find(
(p: any) => p.imaginaryName.toLowerCase() === name
);
matches.push({
name: discoverProfile.imaginaryName,
discover: {
id: discoverProfile.id,
age: discoverProfile.age,
gender: discoverProfile.gender,
sexuality: discoverProfile.sexuality,
bio: discoverProfile.bio?.substring(0, 100),
desires: discoverProfile.desires,
distance: discoverProfile.distance,
photoUrl: discoverProfile.photos?.[0]?.pictureUrls?.medium || discoverProfile.photos?.[0]?.pictureUrl,
interactionStatus: discoverProfile.interactionStatus,
},
whoLikesMe: {
id: whoLikesMeMatch?.id,
age: whoLikesMeMatch?.age,
gender: whoLikesMeMatch?.gender,
sexuality: whoLikesMeMatch?.sexuality,
bio: whoLikesMeMatch?.bio?.substring(0, 100),
desires: whoLikesMeMatch?.desires,
distance: whoLikesMeMatch?.distance,
photoUrl: whoLikesMeMatch?.photos?.[0]?.pictureUrl,
},
});
}
}
// Also check interactionStatus.theirs === 'LIKED' in discover
const profilesThatLikedYou = uniqueDiscoverProfiles.filter(
(p: any) => p.interactionStatus?.theirs === 'LIKED'
);
setResults(prev => [...prev, {
name: 'Cross-Reference: WhoLikesMe vs Discover',
success: true,
data: {
discoverProfileCount: uniqueDiscoverProfiles.length,
whoLikesMeCount: whoLikesMeProfiles.length,
matchesByName: matches.length,
profilesWithTheirsLIKED: profilesThatLikedYou.length,
matches: matches,
profilesThatLikedYouInDiscover: profilesThatLikedYou.map((p: any) => ({
name: p.imaginaryName,
id: p.id,
age: p.age,
gender: p.gender,
sexuality: p.sexuality,
photoUrl: p.photos?.[0]?.pictureUrls?.medium,
})),
analysis: matches.length > 0
? 'FOUND MATCHES! Compare the data below to see what WhoLikesMe hides vs reveals.'
: 'No name matches found. Try loading more discover profiles or the names might not match exactly.',
},
}]);
} catch (err: any) {
setResults(prev => [...prev, {
name: 'Cross-Reference Test',
success: false,
error: err.message,
}]);
} finally {
setLoading(null);
}
};
// Keep fetching until we find profiles with theirs: LIKED
const findProfilesThatLikedYou = async () => {
setLoading('findLiked');
const allProfiles: any[] = [];
const profilesThatLikedYou: any[] = [];
let batchCount = 0;
const maxBatches = 50; // Safety limit
try {
// First get WhoLikesMe names for comparison
const likesResult = await apolloClient.query({
query: WHO_LIKES_ME_QUERY,
variables: { sortBy: 'LAST_INTERACTION' },
fetchPolicy: 'no-cache',
});
const whoLikesMeProfiles = likesResult.data?.interactions?.nodes || [];
const whoLikesMeNames = whoLikesMeProfiles.map((p: any) => p.imaginaryName);
// Keep fetching until we find someone who liked us
while (profilesThatLikedYou.length === 0 && batchCount < maxBatches) {
batchCount++;
const discoverResult = await apolloClient.query({
query: DISCOVER_PROFILES_QUERY,
variables: {
input: {
filters: {
ageRange: [18, 99],
maxDistance: 400, // Max allowed
lookingFor: ['WOMAN', 'MAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE', 'MAN_MAN_COUPLE', 'NON_BINARY'],
recentlyOnline: false,
desiringFor: [],
},
},
},
fetchPolicy: 'no-cache',
});
const profiles = discoverResult.data?.discovery?.nodes || [];
for (const profile of profiles) {
// Check if they liked us
if (profile.interactionStatus?.theirs === 'LIKED') {
profilesThatLikedYou.push({
name: profile.imaginaryName,
id: profile.id,
age: profile.age,
gender: profile.gender,
sexuality: profile.sexuality,
distance: profile.distance,
bio: profile.bio?.substring(0, 100),
desires: profile.desires,
photoUrl: profile.photos?.[0]?.pictureUrls?.medium,
matchesWhoLikesMeName: whoLikesMeNames.includes(profile.imaginaryName),
});
}
}
allProfiles.push(...profiles);
// Update results in real-time
setResults(prev => {
const filtered = prev.filter(r => r.name !== 'Finding Profiles That Liked You...');
return [...filtered, {
name: 'Finding Profiles That Liked You...',
success: true,
data: {
batchesFetched: batchCount,
totalProfilesScanned: allProfiles.length,
foundSoFar: profilesThatLikedYou.length,
status: profilesThatLikedYou.length > 0 ? '🎉 FOUND!' : `Scanning... (batch ${batchCount})`,
},
}];
});
// Small delay between requests
await new Promise(resolve => setTimeout(resolve, 200));
}
// Final result
setResults(prev => {
const filtered = prev.filter(r => r.name !== 'Finding Profiles That Liked You...');
return [...filtered, {
name: '🎉 Profiles That Liked You (from Discover)',
success: profilesThatLikedYou.length > 0,
data: {
batchesFetched: batchCount,
totalProfilesScanned: allProfiles.length,
profilesFound: profilesThatLikedYou.length,
whoLikesMeNames: whoLikesMeNames,
profiles: profilesThatLikedYou,
analysis: profilesThatLikedYou.length > 0
? `Found ${profilesThatLikedYou.length} profile(s) with theirs: "LIKED"! These are REAL profiles who liked you.`
: `Scanned ${allProfiles.length} profiles across ${batchCount} batches but found none with theirs: "LIKED". They may not be in your discover feed yet.`,
},
}];
});
} catch (err: any) {
setResults(prev => [...prev, {
name: 'Find Profiles That Liked You',
success: false,
error: err.message,
}]);
} finally {
setLoading(null);
}
};
return (
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.title}>API Explorer</h1>
<p style={styles.subtitle}>
Testing experimental queries to discover hidden "Who I Liked" endpoint
</p>
</div>
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Experimental Queries</h2>
<button
onClick={runAllQueries}
style={styles.runAllButton}
disabled={runningAll}
>
{runningAll ? 'Running All Queries...' : 'Run All Experimental Queries'}
</button>
<button
onClick={testDirectProfileLookup}
style={{
...styles.runAllButton,
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
marginLeft: '12px',
}}
disabled={loading !== null}
>
{loading === 'directLookup' ? 'Analyzing...' : '🔍 Test WhoLikesMe Paywall Bypass'}
</button>
<button
onClick={crossReferenceProfiles}
style={{
...styles.runAllButton,
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
marginLeft: '12px',
}}
disabled={loading !== null}
>
{loading === 'crossReference' ? 'Fetching 4 batches...' : '🔄 Cross-Reference WhoLikesMe vs Discover'}
</button>
<button
onClick={findProfilesThatLikedYou}
style={{
...styles.runAllButton,
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
marginLeft: '12px',
}}
disabled={loading !== null}
>
{loading === 'findLiked' ? 'Scanning batches...' : '💛 Find Profiles That Liked You (Keep Scanning)'}
</button>
<div style={styles.buttonGrid}>
{EXPERIMENTAL_QUERIES.map(({ name, query }) => (
<button
key={name}
onClick={() => runQuery(name, query)}
style={styles.button(loading === name)}
disabled={loading !== null}
>
{loading === name ? 'Testing...' : `Test: ${name}`}
</button>
))}
<button
onClick={() => runMutation('filteredWhoILiked', FILTERED_WHO_I_LIKED_MUTATION, {
input: { filters: {}, sortBy: 'LAST_INTERACTION' },
})}
style={styles.button(loading === 'filteredWhoILiked')}
disabled={loading !== null}
>
Test: filteredWhoILiked (mutation)
</button>
<button
onClick={() => runQuery('interactions(direction)', INTERACTIONS_OUTGOING_QUERY, {
sortBy: 'LAST_INTERACTION',
direction: 'OUTGOING',
})}
style={styles.button(loading === 'interactions(direction)')}
disabled={loading !== null}
>
Test: interactions(direction: OUTGOING)
</button>
</div>
</div>
<div style={styles.section}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ ...styles.sectionTitle, marginBottom: 0 }}>Results ({results.length})</h2>
{results.length > 0 && (
<button onClick={() => setResults([])} style={styles.clearButton}>
Clear Results
</button>
)}
</div>
{results.length === 0 ? (
<p style={{ color: 'rgba(255,255,255,0.5)', fontFamily: "'Satoshi', sans-serif" }}>
No results yet. Click a button above to test a query.
</p>
) : (
<div style={styles.results}>
{results.map((result) => (
<div key={result.name} style={styles.resultItem(result.success)}>
<div style={styles.resultName}>{result.name}</div>
<div style={styles.resultStatus(result.success)}>
{result.success ? '✓ SUCCESS - Data returned!' : `✗ FAILED: ${result.error}`}
</div>
{result.data && (
<div style={styles.resultData}>
{JSON.stringify(result.data, null, 2)}
</div>
)}
</div>
))}
</div>
)}
</div>
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Query Patterns Being Tested</h2>
<div style={{ color: 'rgba(255,255,255,0.6)', fontFamily: "'Satoshi', sans-serif", fontSize: '13px', lineHeight: '1.8' }}>
<p>Based on existing patterns (<code>whoLikesMe</code>, <code>whoPingsMe</code>), testing:</p>
<ul style={{ marginLeft: '20px' }}>
<li><code>whoILiked</code> - Mirror of whoLikesMe</li>
<li><code>myLikes</code> - Possessive form</li>
<li><code>sentLikes</code> - Action-based</li>
<li><code>likedProfiles</code> - Entity-based</li>
<li><code>profilesILiked</code> - Full form</li>
<li><code>outgoingLikes</code> - Direction-based</li>
<li><code>filteredWhoILiked</code> - Mutation pattern</li>
<li><code>interactions(direction: OUTGOING)</code> - Parameter variation</li>
</ul>
</div>
</div>
</div>
);
}

504
web/src/pages/Chat.tsx Executable file
View File

@@ -0,0 +1,504 @@
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@apollo/client/react';
import { useState, useRef, useEffect } from 'react';
import { GET_CHAT_SUMMARY_QUERY } from '../api/operations/queries';
import { useChannel, useStreamChat } from '../context/StreamChatContext';
import type { StreamMessage } from '../context/StreamChatContext';
import { LoadingPage } from '../components/ui/Loading';
// Proxy cloudinary images through our server to avoid CORS
function proxyImageUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
if (url.includes('cloudinary.com')) {
return url.replace('https://res.cloudinary.com', '/api/images');
}
if (url.includes('fldcdn.com')) {
return url.replace('https://prod.fldcdn.com', '/api/fldcdn');
}
return url;
}
function formatTime(dateString: string | undefined): string {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function formatDate(dateString: string | undefined): string {
if (!dateString) return '';
const date = new Date(dateString);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
} else {
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
}
interface MessageBubbleProps {
message: StreamMessage;
isOwn: boolean;
showDate?: boolean;
showAvatar?: boolean;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
}
function MessageBubble({ message, isOwn, showDate, showAvatar, isFirstInGroup, isLastInGroup }: MessageBubbleProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
// Get border radius based on position in group
const getBorderRadius = () => {
if (isOwn) {
if (isFirstInGroup && isLastInGroup) return 'rounded-[20px] rounded-br-md';
if (isFirstInGroup) return 'rounded-[20px] rounded-br-md rounded-br-md';
if (isLastInGroup) return 'rounded-[20px] rounded-tr-md rounded-br-md';
return 'rounded-[20px] rounded-r-md';
} else {
if (isFirstInGroup && isLastInGroup) return 'rounded-[20px] rounded-bl-md';
if (isFirstInGroup) return 'rounded-[20px] rounded-bl-md';
if (isLastInGroup) return 'rounded-[20px] rounded-tl-md rounded-bl-md';
return 'rounded-[20px] rounded-l-md';
}
};
return (
<>
{showDate && (
<div className="flex justify-center my-6">
<span className="
text-[11px] font-medium tracking-wide uppercase
text-[var(--color-text-muted)]/70
bg-white/[0.03] backdrop-blur-sm
px-4 py-1.5 rounded-full
border border-white/[0.04]
">
{formatDate(message.created_at)}
</span>
</div>
)}
<div className={`
flex ${isOwn ? 'justify-end' : 'justify-start'}
${isLastInGroup ? 'mb-3' : 'mb-0.5'}
animate-fade-up
`}>
<div className={`
flex items-end gap-2
${isOwn ? 'flex-row-reverse' : 'flex-row'}
max-w-[80%] md:max-w-[65%]
`}>
{/* Avatar placeholder for alignment */}
{!isOwn && (
<div className="w-8 h-8 flex-shrink-0">
{showAvatar && message.user?.image && (
<img
src={proxyImageUrl(message.user.image)}
alt=""
className="w-8 h-8 rounded-full object-cover ring-2 ring-white/5"
/>
)}
</div>
)}
<div className="flex flex-col">
<div className={`
px-4 py-2.5
${getBorderRadius()}
${isOwn
? 'bg-gradient-to-br from-[#E85F5C] to-[#D64550] text-white'
: 'bg-white/[0.07] text-[var(--color-text-primary)] border border-white/[0.05]'
}
shadow-lg
${isOwn ? 'shadow-[#E85F5C]/10' : 'shadow-black/20'}
`}>
{/* Attachments */}
{message.attachments?.map((att, i) => {
const imageUrl = proxyImageUrl(att.image_url || att.thumb_url);
if (!imageUrl) return null;
return (
<div key={i} className="relative mb-2 -mx-1 -mt-1 first:-mt-1">
{!imageLoaded && !imageError && (
<div className="w-48 h-48 bg-white/5 rounded-xl animate-pulse flex items-center justify-center">
<svg className="w-8 h-8 text-white/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
{imageError ? (
<div className="w-48 h-32 bg-white/5 rounded-xl flex items-center justify-center">
<span className="text-sm text-white/40">Image unavailable</span>
</div>
) : (
<img
src={imageUrl}
alt="attachment"
className={`max-w-[200px] rounded-xl ${imageLoaded ? 'block' : 'hidden'}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
)}
</div>
);
})}
{/* Message text */}
{message.text && (
<p className="text-[15px] leading-relaxed whitespace-pre-wrap break-words">
{message.text}
</p>
)}
</div>
{/* Timestamp - only show for last message in group */}
{isLastInGroup && (
<p className={`
text-[10px] text-[var(--color-text-muted)]/60 mt-1 px-1
${isOwn ? 'text-right' : 'text-left'}
`}>
{formatTime(message.created_at)}
</p>
)}
</div>
</div>
</div>
</>
);
}
export function ChatPage() {
const { channelId } = useParams<{ channelId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const chatName = searchParams.get('name') || 'Chat';
const chatAvatar = searchParams.get('avatar') || undefined;
const [inputText, setInputText] = useState('');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Stream Chat connection
const { isConnected, isConnecting: streamConnecting, error: streamError, userId } = useStreamChat();
// Channel and messages
const {
messages,
loading: channelLoading,
error: channelError,
sendMessage,
} = useChannel(channelId || '');
// Get chat summary for additional info
const { data: summaryData, loading: summaryLoading } = useQuery(GET_CHAT_SUMMARY_QUERY, {
variables: { streamChatId: channelId },
skip: !channelId,
});
const summary = summaryData?.summary;
const displayName = summary?.name || chatName;
const displayAvatar = proxyImageUrl(summary?.avatarSet?.[0] || chatAvatar);
// Scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async () => {
if (!inputText.trim() || isSending) return;
try {
setIsSending(true);
await sendMessage(inputText.trim());
setInputText('');
inputRef.current?.focus();
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setIsSending(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Loading states
if (summaryLoading || streamConnecting) {
return <LoadingPage message="Connecting to chat..." />;
}
// Stream connection error
if (streamError || channelError) {
return (
<div className="flex flex-col items-center justify-center py-20 animate-fade-up">
<div className="
w-20 h-20 rounded-full
bg-gradient-to-br from-red-500/20 to-red-600/10
flex items-center justify-center mb-6
border border-red-500/20
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-10 h-10 text-red-400">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<h2 className="font-display text-xl font-semibold text-[var(--color-text-primary)] mb-2">
Connection Lost
</h2>
<p className="text-[var(--color-text-muted)] text-sm max-w-xs text-center mb-6">
{streamError || channelError}
</p>
<button
onClick={() => navigate('/messages')}
className="
px-6 py-2.5 rounded-full
bg-white/10 hover:bg-white/15
text-white font-medium
transition-all duration-200
border border-white/10
"
>
Back to Messages
</button>
</div>
);
}
// Process messages for grouping
let lastDate = '';
let lastUserId = '';
const processedMessages = messages.map((msg, index) => {
const msgDate = msg.created_at ? formatDate(msg.created_at) : '';
const showDate = msgDate !== lastDate;
lastDate = msgDate;
const currentUserId = msg.user?.id || '';
const nextMsg = messages[index + 1];
const nextUserId = nextMsg?.user?.id || '';
const isFirstInGroup = showDate || currentUserId !== lastUserId;
const isLastInGroup = !nextMsg || nextUserId !== currentUserId ||
(nextMsg.created_at && msg.created_at &&
new Date(nextMsg.created_at).getTime() - new Date(msg.created_at).getTime() > 60000);
lastUserId = currentUserId;
return {
...msg,
showDate,
showAvatar: isLastInGroup && msg.user?.id !== userId,
isFirstInGroup,
isLastInGroup,
};
});
return (
<div className="flex flex-col h-[calc(100vh-80px)] max-w-4xl mx-auto -mx-4 md:mx-auto">
{/* Header */}
<div className="
flex items-center gap-4 px-4 py-3
bg-gradient-to-b from-[var(--color-surface)] to-transparent
backdrop-blur-xl
sticky top-0 z-10
">
<button
onClick={() => navigate('/messages')}
className="
w-10 h-10 rounded-full
bg-white/[0.05] hover:bg-white/[0.08]
flex items-center justify-center
transition-all duration-200
border border-white/[0.06]
hover:scale-105 active:scale-95
"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="w-5 h-5 text-white/70">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Avatar with glow effect */}
<div className="relative">
{displayAvatar ? (
<img
src={displayAvatar}
alt=""
className="w-11 h-11 rounded-full object-cover ring-2 ring-white/10"
/>
) : (
<div className="w-11 h-11 rounded-full bg-gradient-to-br from-[var(--color-primary)]/30 to-[var(--color-primary)]/10 flex items-center justify-center ring-2 ring-white/10">
<span className="text-lg font-semibold text-white/70">
{displayName.charAt(0)}
</span>
</div>
)}
{/* Online indicator */}
<div className={`
absolute -bottom-0.5 -right-0.5
w-3.5 h-3.5 rounded-full
${isConnected ? 'bg-emerald-400' : 'bg-amber-400'}
border-2 border-[var(--color-void)]
`} />
</div>
<div className="flex-1 min-w-0">
<h2 className="font-semibold text-[var(--color-text-primary)] truncate text-lg">
{displayName}
</h2>
{summary?.targetProfile && (
<p className="text-sm text-[var(--color-text-muted)]/70">
{summary.targetProfile.age} · {summary.targetProfile.gender?.toLowerCase()}
</p>
)}
</div>
</div>
{/* More options */}
<button className="
w-10 h-10 rounded-full
hover:bg-white/[0.05]
flex items-center justify-center
transition-colors
">
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 text-white/50">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
</button>
</div>
{/* Messages area */}
<div className="
flex-1 overflow-y-auto
px-4 py-4
scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent
">
{channelLoading ? (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4">
<div className="
w-12 h-12 rounded-full
border-2 border-[var(--color-primary)]/30 border-t-[var(--color-primary)]
animate-spin
" />
<p className="text-[var(--color-text-muted)]/60 text-sm">Loading messages...</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 animate-fade-up">
<div className="
w-24 h-24 rounded-full
bg-gradient-to-br from-white/[0.05] to-white/[0.02]
flex items-center justify-center mb-6
border border-white/[0.05]
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="w-12 h-12 text-[var(--color-text-muted)]/30">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</div>
<h3 className="text-lg font-medium text-[var(--color-text-primary)] mb-2">
Start the conversation
</h3>
<p className="text-[var(--color-text-muted)]/60 text-sm text-center max-w-xs">
Send a message to begin chatting with {displayName}
</p>
</div>
) : (
<>
{processedMessages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
isOwn={msg.user?.id === userId}
showDate={msg.showDate}
showAvatar={msg.showAvatar}
isFirstInGroup={msg.isFirstInGroup}
isLastInGroup={msg.isLastInGroup}
/>
))}
</>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="
px-4 py-3
bg-gradient-to-t from-[var(--color-void)] via-[var(--color-void)] to-transparent
border-t border-white/[0.04]
">
<div className="flex items-end gap-3">
{/* Attachment button */}
<button className="
w-11 h-11 rounded-full
bg-white/[0.05] hover:bg-white/[0.08]
flex items-center justify-center
transition-all duration-200
border border-white/[0.06]
flex-shrink-0
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5 text-white/50">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
{/* Input field */}
<div className="flex-1 relative">
<input
ref={inputRef}
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Type a message..."
disabled={!isConnected}
className="
w-full px-5 py-3
bg-white/[0.05]
border border-white/[0.08]
rounded-full
text-[var(--color-text-primary)] text-[15px]
placeholder-[var(--color-text-muted)]/50
focus:outline-none focus:border-[var(--color-primary)]/40 focus:bg-white/[0.07]
transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed
"
/>
</div>
{/* Send button */}
<button
onClick={handleSend}
disabled={!inputText.trim() || isSending || !isConnected}
className={`
w-11 h-11 rounded-full
flex items-center justify-center
transition-all duration-200
flex-shrink-0
${inputText.trim() && !isSending && isConnected
? 'bg-gradient-to-br from-[#E85F5C] to-[#D64550] text-white shadow-lg shadow-[#E85F5C]/25 hover:shadow-[#E85F5C]/40 hover:scale-105 active:scale-95'
: 'bg-white/[0.05] text-white/30 border border-white/[0.06] cursor-not-allowed'
}
`}
>
{isSending ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 translate-x-0.5">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
)}
</button>
</div>
</div>
</div>
);
}

737
web/src/pages/Discover.tsx Executable file
View File

@@ -0,0 +1,737 @@
import { useQuery } from '@apollo/client/react';
import { gql } from '@apollo/client';
import { DISCOVER_PROFILES_QUERY } from '../api/operations/queries';
import { ProfileCard } from '../components/profile/ProfileCard';
import { ProfileDetailModal } from '../components/profile/ProfileDetailModal';
import { LoadingPage, LoadingCards } from '../components/ui/Loading';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocation } from '../hooks/useLocation';
import { apolloClient } from '../api/client';
// Separate query for load more to avoid cache conflicts
const LOAD_MORE_PROFILES_QUERY = gql`
query LoadMoreProfiles($input: ProfileDiscoveryInput!) {
discovery(input: $input) {
nodes {
id
age
imaginaryName
gender
sexuality
isIncognito
isMajestic
verificationStatus
connectionGoals
desires
bio
interests
distance { km mi __typename }
photos {
id
pictureUrls { small medium large __typename }
pictureType
__typename
}
interactionStatus { message mine theirs __typename }
__typename
}
hasNextBatch
__typename
}
}
`;
const styles = {
container: {
paddingBottom: '48px',
},
// Header
header: {
marginBottom: '32px',
},
title: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '36px',
fontWeight: 700,
color: '#ffffff',
margin: 0,
marginBottom: '8px',
letterSpacing: '-0.02em',
},
subtitle: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
color: 'rgba(255,255,255,0.5)',
margin: 0,
},
// Filter section
filterContainer: {
display: 'flex',
gap: '12px',
marginBottom: '32px',
overflowX: 'auto' as const,
paddingBottom: '8px',
},
filterButton: (isActive: boolean, variant: 'all' | 'liked' | 'passed' | 'online') => {
const gradients = {
all: 'linear-gradient(135deg, #be3144 0%, #c41e3a 100%)',
liked: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
passed: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
online: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
};
return {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '14px 24px',
borderRadius: '50px',
fontSize: '14px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
whiteSpace: 'nowrap' as const,
border: isActive ? 'none' : '1px solid rgba(255,255,255,0.08)',
background: isActive ? gradients[variant] : 'rgba(255,255,255,0.03)',
color: isActive ? '#ffffff' : 'rgba(255,255,255,0.7)',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: isActive ? '0 4px 20px rgba(0,0,0,0.3)' : 'none',
};
},
filterBadge: (isActive: boolean, variant: 'all' | 'liked' | 'passed') => {
const colors = {
all: { bg: 'rgba(190,49,68,0.25)', color: '#f4a5b0' },
liked: { bg: 'rgba(34,197,94,0.25)', color: '#86efac' },
passed: { bg: 'rgba(100,116,139,0.25)', color: '#cbd5e1' },
};
return {
padding: '4px 10px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 700,
fontFamily: "'Satoshi', sans-serif",
background: isActive ? 'rgba(255,255,255,0.2)' : colors[variant].bg,
color: isActive ? '#ffffff' : colors[variant].color,
};
},
// Profile grid
profileGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '16px',
},
profileGridMd: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '16px',
},
// Empty states
emptyState: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: '80px 20px',
},
emptyIcon: (variant: 'error' | 'empty' | 'liked' | 'passed') => {
const colors = {
error: { bg: 'rgba(239,68,68,0.1)', color: '#ef4444' },
empty: { bg: 'linear-gradient(135deg, rgba(190,49,68,0.15) 0%, rgba(190,49,68,0.05) 100%)', color: '#be3144' },
liked: { bg: 'rgba(34,197,94,0.1)', color: '#22c55e' },
passed: { bg: 'rgba(100,116,139,0.1)', color: '#64748b' },
};
return {
width: '80px',
height: '80px',
borderRadius: '50%',
background: colors[variant].bg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
};
},
emptyTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '22px',
fontWeight: 600,
color: '#ffffff',
marginBottom: '8px',
textAlign: 'center' as const,
},
emptyText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
color: 'rgba(255,255,255,0.5)',
textAlign: 'center' as const,
maxWidth: '280px',
},
// Load more button
loadMoreContainer: {
display: 'flex',
justifyContent: 'center',
marginTop: '48px',
},
loadMoreButton: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '16px 32px',
borderRadius: '50px',
fontSize: '15px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#ffffff',
cursor: 'pointer',
transition: 'all 0.2s ease',
},
// Match modal
matchOverlay: {
position: 'fixed' as const,
inset: 0,
background: 'rgba(0,0,0,0.92)',
backdropFilter: 'blur(20px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 60,
},
matchContent: {
textAlign: 'center' as const,
padding: '40px',
},
matchIconContainer: {
position: 'relative' as const,
marginBottom: '32px',
},
matchIcon: {
width: '100px',
height: '100px',
margin: '0 auto',
borderRadius: '50%',
background: 'linear-gradient(135deg, #be3144 0%, #c41e3a 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 60px rgba(190,49,68,0.5), 0 0 100px rgba(190,49,68,0.3)',
},
matchSparkle: (position: { top?: string; bottom?: string; left?: string; right?: string }) => ({
position: 'absolute' as const,
width: '16px',
height: '16px',
color: '#fbbf24',
...position,
}),
matchTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '42px',
fontWeight: 700,
color: '#ffffff',
margin: 0,
marginBottom: '12px',
letterSpacing: '-0.02em',
},
matchSubtitle: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '18px',
color: 'rgba(255,255,255,0.6)',
margin: 0,
marginBottom: '40px',
},
matchButtons: {
display: 'flex',
gap: '16px',
justifyContent: 'center',
},
matchButton: (isPrimary: boolean) => ({
padding: '16px 32px',
borderRadius: '50px',
fontSize: '15px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
border: isPrimary ? 'none' : '1px solid rgba(255,255,255,0.2)',
background: isPrimary ? 'linear-gradient(135deg, #be3144 0%, #c41e3a 100%)' : 'rgba(255,255,255,0.05)',
color: '#ffffff',
cursor: 'pointer',
transition: 'all 0.2s ease',
}),
// Loading grid
loadingGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '16px',
marginTop: '16px',
},
};
type FilterType = 'all' | 'liked' | 'disliked';
export function DiscoverPage() {
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [matchAlert, setMatchAlert] = useState(false);
const [allProfiles, setAllProfiles] = useState<any[]>([]);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState<boolean | null>(null);
const [filter, setFilter] = useState<FilterType>(() => {
return (localStorage.getItem('feeld_discover_filter') as FilterType) || 'all';
});
const [recentlyOnlineOnly, setRecentlyOnlineOnly] = useState(() => {
return localStorage.getItem('feeld_discover_recently_online') === 'true';
});
const initialLoadDone = useRef(false);
const savedLikedMeProfiles = useRef<Set<string>>(new Set());
const { location } = useLocation();
// Fetch rotation location (what the cron actually set on Feeld)
const [rotationLocation, setRotationLocation] = useState<string | null>(null);
useEffect(() => {
fetch('/api/location-rotation/status')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.enabled && data?.lastResult?.status === 'success') {
setRotationLocation(data.lastResult.location);
}
})
.catch(() => {});
}, []);
// Save all discovered profiles to backend cache for Likes page enrichment
const saveDiscoveredProfiles = useCallback(async (profiles: any[]) => {
if (!profiles.length) return;
const safeStr = (v: any) => (typeof v === 'string' ? v : '');
// Strip photos (signed URLs expire quickly) and sanitize __typename fields
const stripped = profiles.map(p => ({
id: p.id,
imaginaryName: p.imaginaryName,
age: p.age,
gender: safeStr(p.gender),
sexuality: safeStr(p.sexuality),
bio: p.bio,
desires: p.desires,
connectionGoals: p.connectionGoals,
verificationStatus: p.verificationStatus,
interactionStatus: p.interactionStatus,
discoveredAt: new Date().toISOString(),
}));
try {
const response = await fetch('/api/discovered-profiles/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profiles: stripped }),
});
if (response.ok) {
const result = await response.json();
console.log(`Saved ${result.added} new, ${result.updated} updated discovered profiles (${result.total} total)`);
}
} catch (err) {
console.error('Failed to save discovered profiles:', err);
}
}, []);
// Save profiles that liked me to the backend for matching on Likes page
const saveWhoLikedMeProfile = useCallback(async (profile: any) => {
if (savedLikedMeProfiles.current.has(profile.id)) {
return; // Already saved this profile
}
try {
const response = await fetch('/api/who-liked-you', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile }),
});
if (response.ok) {
savedLikedMeProfiles.current.add(profile.id);
console.log('Saved who-liked-me profile from Discover:', profile.imaginaryName, profile.id);
}
} catch (err) {
console.error('Failed to save who-liked-me profile:', err);
}
}, []);
const [locationKey, setLocationKey] = useState(0);
const { data, loading, error, refetch } = useQuery(DISCOVER_PROFILES_QUERY, {
variables: {
input: {
filters: {
ageRange: [22, 59],
maxDistance: 100,
lookingFor: ['WOMAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE'],
recentlyOnline: recentlyOnlineOnly,
desiringFor: [],
},
},
},
fetchPolicy: 'network-only', // Always fetch fresh data
});
// Persist filter changes to localStorage
useEffect(() => {
localStorage.setItem('feeld_discover_filter', filter);
}, [filter]);
useEffect(() => {
localStorage.setItem('feeld_discover_recently_online', String(recentlyOnlineOnly));
}, [recentlyOnlineOnly]);
// Refetch when recentlyOnlineOnly changes
useEffect(() => {
if (initialLoadDone.current) {
console.log('Recently online filter changed:', recentlyOnlineOnly);
setAllProfiles([]);
initialLoadDone.current = false;
refetch();
}
}, [recentlyOnlineOnly]);
// Refetch when location changes (location is set via DeviceLocationUpdate mutation)
useEffect(() => {
if (location && initialLoadDone.current) {
console.log('Location changed, refetching discover profiles...', location);
setAllProfiles([]);
initialLoadDone.current = false;
setLocationKey(k => k + 1);
refetch();
}
}, [location?.latitude, location?.longitude]);
// Initialize profiles only on first successful load
useEffect(() => {
if (!initialLoadDone.current && data?.discovery?.nodes) {
setAllProfiles(data.discovery.nodes);
setHasMore(data.discovery.hasNextBatch ?? false);
initialLoadDone.current = true;
// Cache all discovered profiles for Likes page enrichment
saveDiscoveredProfiles(data.discovery.nodes);
}
}, [data]);
// Save profiles that liked me to the backend for matching on Likes page
useEffect(() => {
const profilesToCheck = allProfiles.length > 0 ? allProfiles : (data?.discovery?.nodes || []);
const likedMeProfiles = profilesToCheck.filter(
(p: any) => p.interactionStatus?.theirs === 'LIKED'
);
likedMeProfiles.forEach((profile: any) => {
saveWhoLikedMeProfile(profile);
});
}, [allProfiles, data?.discovery?.nodes, saveWhoLikedMeProfile]);
const handleLoadMore = async () => {
if (loadingMore) return; // Prevent double-click
const currentProfiles = allProfiles.length > 0 ? allProfiles : (data?.discovery?.nodes || []);
const alreadyShownProfileIDs = currentProfiles.map((p: any) => p.id);
setLoadingMore(true);
try {
const result = await apolloClient.query({
query: LOAD_MORE_PROFILES_QUERY,
variables: {
input: {
filters: {
ageRange: [22, 59],
maxDistance: 100,
lookingFor: ['WOMAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE'],
recentlyOnline: recentlyOnlineOnly,
desiringFor: [],
alreadyShownProfileIDs,
},
},
},
fetchPolicy: 'no-cache',
});
const newProfiles = result.data?.discovery?.nodes || [];
setAllProfiles(prev => [...prev, ...newProfiles]);
setHasMore(result.data?.discovery?.hasNextBatch ?? false);
// Cache all discovered profiles for Likes page enrichment
saveDiscoveredProfiles(newProfiles);
} catch (err) {
console.error('Failed to load more profiles:', err);
} finally {
setLoadingMore(false);
}
};
if (loading) return <LoadingPage message="Finding connections..." />;
if (error) {
return (
<div style={styles.emptyState}>
<div style={styles.emptyIcon('error')}>
<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5" style={{ width: '36px', height: '36px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<h2 style={styles.emptyTitle}>Connection Error</h2>
<p style={styles.emptyText}>{error.message}</p>
</div>
);
}
const rawProfiles = allProfiles.length > 0 ? allProfiles : (data?.discovery?.nodes || []);
const hasNextBatch = hasMore ?? (data?.discovery?.hasNextBatch ?? false);
// Filter profiles based on selection
const profiles = rawProfiles.filter((profile: any) => {
if (filter === 'all') return true;
const theirs = profile.interactionStatus?.theirs;
if (filter === 'liked') return theirs === 'LIKED';
if (filter === 'disliked') return theirs === 'DISLIKED';
return true;
});
// Count for filter badges
const likedCount = rawProfiles.filter((p: any) => p.interactionStatus?.theirs === 'LIKED').length;
const dislikedCount = rawProfiles.filter((p: any) => p.interactionStatus?.theirs === 'DISLIKED').length;
if (rawProfiles.length === 0) {
return (
<div style={styles.emptyState}>
<div style={styles.emptyIcon('empty')}>
<svg viewBox="0 0 24 24" fill="none" stroke="#be3144" strokeWidth="1" style={{ width: '40px', height: '40px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<h2 style={styles.emptyTitle}>No Profiles Yet</h2>
<p style={styles.emptyText}>Check back soon for new connections</p>
</div>
);
}
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<h1 style={styles.title}>Discover</h1>
<p style={styles.subtitle}>
{rawProfiles.length} {rawProfiles.length === 1 ? 'profile' : 'profiles'} waiting
{(rotationLocation || location) && (
<span style={{ marginLeft: '12px', color: 'rgba(255,255,255,0.4)' }}>
📍 {rotationLocation || location?.name || `${location?.latitude.toFixed(2)}, ${location?.longitude.toFixed(2)}`}
{rotationLocation && <span style={{ marginLeft: '6px', fontSize: '11px', color: 'rgba(245,158,11,0.6)' }}>(auto)</span>}
</span>
)}
</p>
</div>
{/* Filter Buttons */}
<div style={styles.filterContainer}>
<button
onClick={() => setFilter('all')}
style={styles.filterButton(filter === 'all', 'all')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
All
<span style={styles.filterBadge(filter === 'all', 'all')}>
{rawProfiles.length}
</span>
</button>
<button
onClick={() => setFilter('liked')}
style={styles.filterButton(filter === 'liked', 'liked')}
>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '16px', height: '16px' }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
Likes You
{likedCount > 0 && (
<span style={styles.filterBadge(filter === 'liked', 'liked')}>
{likedCount}
</span>
)}
</button>
<button
onClick={() => setFilter('disliked')}
style={styles.filterButton(filter === 'disliked', 'passed')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Passed
{dislikedCount > 0 && (
<span style={styles.filterBadge(filter === 'disliked', 'passed')}>
{dislikedCount}
</span>
)}
</button>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Recently Online Toggle */}
<button
onClick={() => setRecentlyOnlineOnly(!recentlyOnlineOnly)}
style={styles.filterButton(recentlyOnlineOnly, 'online')}
>
<svg viewBox="0 0 24 24" fill={recentlyOnlineOnly ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
<circle cx="12" cy="12" r="3" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
</svg>
Recently Online
</button>
</div>
{/* Empty filter state */}
{profiles.length === 0 ? (
<div style={styles.emptyState}>
<div style={styles.emptyIcon(filter === 'liked' ? 'liked' : 'passed')}>
{filter === 'liked' ? (
<svg viewBox="0 0 24 24" fill="#22c55e" style={{ width: '36px', height: '36px' }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="#64748b" strokeWidth="1.5" style={{ width: '36px', height: '36px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
<h3 style={styles.emptyTitle}>
{filter === 'liked' ? 'No likes yet' : 'No passes yet'}
</h3>
<p style={styles.emptyText}>
{filter === 'liked'
? 'People who like you will appear here'
: "Profiles you've passed on will appear here"
}
</p>
</div>
) : (
<>
{/* Profile Grid */}
<div style={styles.profileGridMd}>
{profiles.map((profile: any, index: number) => (
<ProfileCard
key={profile.id}
profile={profile}
onClick={() => setSelectedProfileId(profile.id)}
index={index}
/>
))}
</div>
{/* Loading more */}
{loadingMore && (
<div style={styles.loadingGrid}>
<LoadingCards count={4} />
</div>
)}
</>
)}
{/* Load More Button */}
{hasNextBatch && profiles.length > 0 && !loadingMore && (
<div style={styles.loadMoreContainer}>
<button
onClick={handleLoadMore}
style={styles.loadMoreButton}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)';
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '18px', height: '18px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
Load More Profiles
</button>
</div>
)}
{/* Profile Detail Modal */}
{selectedProfileId && (
<ProfileDetailModal
profileId={selectedProfileId}
onClose={() => setSelectedProfileId(null)}
onMatch={() => setMatchAlert(true)}
/>
)}
{/* Match Alert */}
{matchAlert && (
<div
style={styles.matchOverlay}
onClick={() => setMatchAlert(false)}
>
<div style={styles.matchContent} onClick={(e) => e.stopPropagation()}>
{/* Celebration animation */}
<div style={styles.matchIconContainer}>
<div style={styles.matchIcon}>
<svg viewBox="0 0 24 24" fill="#ffffff" style={{ width: '48px', height: '48px' }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
{/* Sparkles */}
<div style={styles.matchSparkle({ top: '-8px', left: '-8px' })}>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
<div style={styles.matchSparkle({ top: '-4px', right: '-12px' })}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '12px', height: '12px' }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
<div style={styles.matchSparkle({ bottom: '0px', right: '-4px' })}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '10px', height: '10px' }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
</div>
<h2 style={styles.matchTitle}>It's a Match!</h2>
<p style={styles.matchSubtitle}>You can now message each other</p>
<div style={styles.matchButtons}>
<button
onClick={() => setMatchAlert(false)}
style={styles.matchButton(false)}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
}}
>
Keep Browsing
</button>
<button
onClick={() => setMatchAlert(false)}
style={styles.matchButton(true)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.02)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
Send Message
</button>
</div>
</div>
</div>
)}
</div>
);
}

1395
web/src/pages/Likes.tsx Executable file

File diff suppressed because it is too large Load Diff

111
web/src/pages/Messages.tsx Executable file
View File

@@ -0,0 +1,111 @@
import { useQuery } from '@apollo/client/react';
import { useNavigate } from 'react-router-dom';
import { LIST_SUMMARIES_QUERY } from '../api/operations/queries';
import { ChatListItem } from '../components/chat/ChatListItem';
import { LoadingPage } from '../components/ui/Loading';
export function MessagesPage() {
const navigate = useNavigate();
const { data, loading, error } = useQuery(LIST_SUMMARIES_QUERY, {
variables: { limit: 30 },
});
if (loading) return <LoadingPage message="Loading conversations..." />;
if (error) {
return (
<div className="flex flex-col items-center justify-center py-20 animate-fade-up">
<div className="
w-16 h-16 rounded-full
bg-red-500/10
flex items-center justify-center
mb-6
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-8 h-8 text-red-400">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<h2 className="font-display text-xl font-semibold text-[var(--color-text-primary)] mb-2">
Unable to Load Messages
</h2>
<p className="text-[var(--color-text-muted)] text-sm max-w-xs text-center">
{error.message}
</p>
</div>
);
}
const chats = data?.summaries?.nodes || [];
if (chats.length === 0) {
return (
<div className="pb-8">
{/* Header */}
<div className="mb-8 animate-fade-up">
<h1 className="font-display text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-2">
Messages
</h1>
<p className="text-[var(--color-text-muted)]">
Your conversations
</p>
</div>
{/* Empty State */}
<div className="flex flex-col items-center justify-center py-20 animate-fade-up" style={{ animationDelay: '50ms' }}>
<div className="
w-20 h-20 rounded-full
bg-gradient-to-br from-[var(--color-surface-elevated)] to-[var(--color-surface)]
flex items-center justify-center
mb-6
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="w-10 h-10 text-[var(--color-text-muted)]/50">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</div>
<h2 className="font-display text-xl font-semibold text-[var(--color-text-primary)] mb-2">
No Conversations Yet
</h2>
<p className="text-[var(--color-text-muted)] text-sm max-w-xs text-center">
Match with someone to start chatting
</p>
</div>
</div>
);
}
return (
<div className="pb-8">
{/* Header */}
<div className="mb-8 animate-fade-up">
<h1 className="font-display text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-2">
Messages
</h1>
<p className="text-[var(--color-text-muted)]">
{chats.length} {chats.length === 1 ? 'conversation' : 'conversations'}
</p>
</div>
{/* Chat List */}
<div className="space-y-2">
{chats.map((chat: any, index: number) => (
<div
key={chat.id}
className="animate-fade-up"
style={{ animationDelay: `${(index + 1) * 30}ms` }}
>
<ChatListItem
chat={chat}
onClick={() => {
const params = new URLSearchParams();
if (chat.name) params.set('name', chat.name);
if (chat.avatarSet?.[0]) params.set('avatar', chat.avatarSet[0]);
navigate(`/chat/${chat.streamChannelId}?${params.toString()}`);
}}
/>
</div>
))}
</div>
</div>
);
}

904
web/src/pages/Profile.tsx Executable file
View File

@@ -0,0 +1,904 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@apollo/client/react';
import { PROFILE_QUERY } from '../api/operations/queries';
import { PROFILE_UPDATE_MUTATION } from '../api/operations/mutations';
import { TEST_CREDENTIALS } from '../config/constants';
import { LoadingPage } from '../components/ui/Loading';
import { ProxiedImage } from '../components/ui/ProxiedImage';
import { getBestImageUrl } from '../utils/images';
// Available options for multi-select fields (from API LocalisedDesireCategory)
const DESIRE_OPTIONS = [
'AFTERCARE', 'BDSM', 'BONDAGE', 'BRAT', 'BRAT_TAMER', 'CASUAL', 'CELIBATE',
'COMMUNICATION', 'CONNECTION', 'COUPLES', 'CUDDLING', 'DATES', 'DOMINANTS',
'EDGING', 'ENM', 'EXPLORATION', 'FF', 'FFF', 'FFFF', 'FFM', 'FLIRTING',
'FOREPLAY', 'FREEDOMME', 'FRIENDSHIPS', 'FUN', 'FWB', 'GGG', 'GROUP',
'INTIMACY', 'KINK', 'KISSING', 'MASSAGE', 'MF', 'MFMF', 'MM', 'MMF', 'MMM',
'MMMM', 'MONOGAMY', 'OPEN_RELATIONSHIP', 'PARTIES', 'POLY', 'RELATIONSHIP',
'ROLE_PLAY', 'ROUGH', 'SENSUAL', 'SINGLES', 'SUBMISSIVES', 'SWITCH',
'TEXTING', 'THREESOME', 'TOYS', 'VANILLA', 'WATCHING'
];
const LOOKING_FOR_OPTIONS = [
'MAN', 'WOMAN', 'NON_BINARY',
'MAN_WOMAN_COUPLE', 'MAN_MAN_COUPLE', 'WOMAN_WOMAN_COUPLE'
];
const CONNECTION_GOAL_OPTIONS = [
'DATING', 'LONG_TERM_CONNECTIONS', 'NON_MONOGAMY', 'OPEN_RELATIONSHIP', 'FWB', 'CASUAL'
];
const styles = {
container: {
maxWidth: '900px',
margin: '0 auto',
paddingBottom: '48px',
},
// Hero section with large photo
heroSection: {
position: 'relative' as const,
height: '400px',
borderRadius: '24px',
overflow: 'hidden',
marginBottom: '24px',
},
heroImage: {
width: '100%',
height: '100%',
objectFit: 'cover' as const,
},
heroOverlay: {
position: 'absolute' as const,
inset: 0,
background: 'linear-gradient(180deg, transparent 0%, transparent 40%, rgba(10,10,12,0.7) 70%, rgba(10,10,12,0.95) 100%)',
},
heroContent: {
position: 'absolute' as const,
bottom: 0,
left: 0,
right: 0,
padding: '32px',
},
heroNameRow: {
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '8px',
},
heroName: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '42px',
fontWeight: 700,
color: '#ffffff',
margin: 0,
letterSpacing: '-0.02em',
},
badgeRow: {
display: 'flex',
gap: '8px',
},
majesticBadge: {
width: '32px',
height: '32px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(245, 158, 11, 0.4)',
},
verifiedBadge: {
width: '32px',
height: '32px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.4)',
},
heroMeta: {
display: 'flex',
alignItems: 'center',
gap: '12px',
color: 'rgba(255,255,255,0.8)',
fontSize: '16px',
fontFamily: "'Satoshi', sans-serif",
},
metaDot: {
width: '4px',
height: '4px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.4)',
},
// Main content grid
contentGrid: {
display: 'grid',
gridTemplateColumns: '1fr',
gap: '24px',
},
// Card styles
card: {
background: 'rgba(255,255,255,0.03)',
borderRadius: '20px',
border: '1px solid rgba(255,255,255,0.06)',
padding: '28px',
},
cardHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '20px',
},
cardTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '13px',
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
color: 'rgba(255,255,255,0.4)',
margin: 0,
},
editCardButton: {
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
background: 'rgba(190,49,68,0.1)',
border: '1px solid rgba(190,49,68,0.3)',
borderRadius: '8px',
color: '#f4a5b0',
fontSize: '12px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s ease',
},
bioText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '16px',
lineHeight: 1.7,
color: 'rgba(255,255,255,0.85)',
whiteSpace: 'pre-wrap' as const,
},
// Tags/badges
tagContainer: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '10px',
},
tag: (variant: 'primary' | 'desire' | 'interest', isSelected?: boolean) => {
const colors = {
primary: {
background: isSelected
? 'linear-gradient(135deg, rgba(190,49,68,0.4) 0%, rgba(190,49,68,0.3) 100%)'
: 'linear-gradient(135deg, rgba(190,49,68,0.2) 0%, rgba(190,49,68,0.1) 100%)',
border: isSelected
? '2px solid rgba(190,49,68,0.6)'
: '1px solid rgba(190,49,68,0.3)',
color: '#f4a5b0',
},
desire: {
background: isSelected
? 'rgba(255,255,255,0.15)'
: 'rgba(255,255,255,0.05)',
border: isSelected
? '2px solid rgba(255,255,255,0.4)'
: '1px solid rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.8)',
},
interest: {
background: isSelected
? 'linear-gradient(135deg, rgba(16,185,129,0.3) 0%, rgba(16,185,129,0.2) 100%)'
: 'linear-gradient(135deg, rgba(16,185,129,0.15) 0%, rgba(16,185,129,0.08) 100%)',
border: isSelected
? '2px solid rgba(16,185,129,0.5)'
: '1px solid rgba(16,185,129,0.25)',
color: '#6ee7b7',
},
};
return {
...colors[variant],
padding: '10px 18px',
borderRadius: '24px',
fontSize: '14px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s ease',
};
},
// Photos section
photosHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '20px',
},
photosTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '22px',
fontWeight: 600,
color: '#ffffff',
},
photosCount: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
fontWeight: 500,
color: 'rgba(255,255,255,0.5)',
background: 'rgba(255,255,255,0.05)',
padding: '6px 14px',
borderRadius: '20px',
},
photosGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
},
photoItem: {
aspectRatio: '1',
borderRadius: '16px',
overflow: 'hidden',
background: 'rgba(255,255,255,0.03)',
cursor: 'pointer',
},
photoImage: {
width: '100%',
height: '100%',
objectFit: 'cover' as const,
transition: 'transform 0.5s ease',
},
// Empty states
emptyState: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: '80px 20px',
},
emptyIcon: {
width: '80px',
height: '80px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.03)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
},
emptyTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '22px',
fontWeight: 600,
color: '#ffffff',
marginBottom: '8px',
},
emptyText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
color: 'rgba(255,255,255,0.5)',
textAlign: 'center' as const,
},
// Error state
errorIcon: {
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'rgba(239,68,68,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
},
// Header with refresh button
pageHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '24px',
},
pageTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '28px',
fontWeight: 700,
color: '#ffffff',
margin: 0,
},
headerButtons: {
display: 'flex',
gap: '12px',
},
refreshButton: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 20px',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '12px',
color: 'rgba(255,255,255,0.8)',
fontSize: '14px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s ease',
},
editButton: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 20px',
background: 'linear-gradient(135deg, #be3144 0%, #a02a3b 100%)',
border: 'none',
borderRadius: '12px',
color: '#ffffff',
fontSize: '14px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s ease',
},
cancelButton: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 20px',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '12px',
color: 'rgba(255,255,255,0.8)',
fontSize: '14px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s ease',
},
saveButton: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 24px',
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
border: 'none',
borderRadius: '12px',
color: '#ffffff',
fontSize: '14px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s ease',
},
// Edit mode styles
editTextarea: {
width: '100%',
minHeight: '120px',
padding: '16px',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '12px',
color: 'rgba(255,255,255,0.9)',
fontSize: '16px',
lineHeight: 1.6,
fontFamily: "'Satoshi', sans-serif",
resize: 'vertical' as const,
outline: 'none',
},
editLabel: {
display: 'block',
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
fontWeight: 500,
color: 'rgba(255,255,255,0.6)',
marginBottom: '8px',
},
editHint: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '12px',
color: 'rgba(255,255,255,0.4)',
marginTop: '8px',
},
selectableTag: {
cursor: 'pointer',
userSelect: 'none' as const,
},
// Toast notification
toast: {
position: 'fixed' as const,
bottom: '100px',
left: '50%',
transform: 'translateX(-50%)',
padding: '14px 24px',
borderRadius: '12px',
fontSize: '14px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
zIndex: 1000,
animation: 'slideUp 0.3s ease',
},
toastSuccess: {
background: 'linear-gradient(135deg, rgba(16,185,129,0.95) 0%, rgba(5,150,105,0.95) 100%)',
color: '#ffffff',
},
toastError: {
background: 'linear-gradient(135deg, rgba(239,68,68,0.95) 0%, rgba(220,38,38,0.95) 100%)',
color: '#ffffff',
},
};
interface EditFormState {
bio: string;
desires: string[];
lookingFor: string[];
connectionGoals: string[];
}
export function ProfilePage() {
const [isRefreshing, setIsRefreshing] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [editForm, setEditForm] = useState<EditFormState>({
bio: '',
desires: [],
lookingFor: [],
connectionGoals: [],
});
const { data, loading, error, refetch } = useQuery(PROFILE_QUERY, {
variables: { profileId: TEST_CREDENTIALS.PROFILE_ID },
fetchPolicy: 'network-only', // Always fetch from network, don't use cache
});
const [updateProfile] = useMutation(PROFILE_UPDATE_MUTATION, {
refetchQueries: [
{ query: PROFILE_QUERY, variables: { profileId: TEST_CREDENTIALS.PROFILE_ID } }
],
});
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refetch();
} finally {
setIsRefreshing(false);
}
};
const handleStartEdit = () => {
const profile = data?.profile;
if (profile) {
setEditForm({
bio: profile.bio || '',
desires: profile.desires || [],
lookingFor: profile.lookingFor || [],
connectionGoals: profile.connectionGoals || [],
});
setIsEditing(true);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditForm({
bio: '',
desires: [],
lookingFor: [],
connectionGoals: [],
});
};
const handleSave = async () => {
setIsSaving(true);
try {
const input: Record<string, any> = {};
const profile = data?.profile;
if (!profile) return;
// Only include changed fields
if (editForm.bio !== (profile.bio || '')) {
input.bio = editForm.bio;
}
if (JSON.stringify(editForm.desires) !== JSON.stringify(profile.desires || [])) {
input.desires = editForm.desires;
}
if (JSON.stringify(editForm.lookingFor) !== JSON.stringify(profile.lookingFor || [])) {
input.lookingFor = editForm.lookingFor;
}
if (JSON.stringify(editForm.connectionGoals) !== JSON.stringify(profile.connectionGoals || [])) {
input.connectionGoals = editForm.connectionGoals;
}
if (Object.keys(input).length === 0) {
setToast({ message: 'No changes to save', type: 'error' });
setTimeout(() => setToast(null), 3000);
return;
}
console.log('Updating profile with:', input);
const result = await updateProfile({
variables: { input },
});
console.log('Profile update result:', result);
console.log('Updated profile data:', result.data?.profileUpdate);
setToast({ message: 'Profile updated successfully!', type: 'success' });
setIsEditing(false);
// Force network fetch to bypass Apollo cache
const refetchResult = await refetch();
console.log('Refetch result:', refetchResult.data?.profile?.lookingFor);
} catch (err: any) {
console.error('Failed to update profile:', err);
const errorMessage = err?.graphQLErrors?.[0]?.message || err?.message || 'Failed to update profile';
setToast({ message: errorMessage, type: 'error' });
} finally {
setIsSaving(false);
setTimeout(() => setToast(null), 3000);
}
};
const toggleArrayItem = (field: 'desires' | 'lookingFor' | 'connectionGoals', item: string) => {
setEditForm(prev => {
const current = prev[field];
const newValue = current.includes(item)
? current.filter(i => i !== item)
: [...current, item];
return { ...prev, [field]: newValue };
});
};
if (loading) return <LoadingPage message="Loading your profile..." />;
if (error) {
return (
<div style={styles.emptyState}>
<div style={styles.errorIcon}>
<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5" style={{ width: '32px', height: '32px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<h2 style={styles.emptyTitle}>Unable to Load Profile</h2>
<p style={styles.emptyText}>{error.message}</p>
</div>
);
}
const profile = data?.profile;
if (!profile) {
return (
<div style={styles.emptyState}>
<div style={styles.emptyIcon}>
<svg viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1" style={{ width: '40px', height: '40px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<h2 style={styles.emptyTitle}>Profile Not Found</h2>
</div>
);
}
const primaryPhoto = getBestImageUrl(profile.photos?.[0]?.pictureUrls, 'large');
return (
<div style={styles.container}>
{/* Toast Notification */}
{toast && (
<div style={{
...styles.toast,
...(toast.type === 'success' ? styles.toastSuccess : styles.toastError),
}}>
{toast.message}
</div>
)}
{/* Header with Refresh/Edit */}
<div style={styles.pageHeader}>
<h1 style={styles.pageTitle}>My Profile</h1>
<div style={styles.headerButtons}>
{isEditing ? (
<>
<button
onClick={handleCancelEdit}
style={styles.cancelButton}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
style={{
...styles.saveButton,
opacity: isSaving ? 0.6 : 1,
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
</>
) : (
<>
<button
onClick={handleRefresh}
disabled={isRefreshing}
style={{
...styles.refreshButton,
opacity: isRefreshing ? 0.6 : 1,
cursor: isRefreshing ? 'not-allowed' : 'pointer',
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{
width: '16px',
height: '16px',
animation: isRefreshing ? 'spin 1s linear infinite' : 'none',
}}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</button>
<button onClick={handleStartEdit} style={styles.editButton}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
Edit Profile
</button>
</>
)}
</div>
</div>
{/* Hero Section */}
<div style={styles.heroSection}>
<ProxiedImage
src={primaryPhoto}
alt={profile.imaginaryName}
style={styles.heroImage}
fallback={
<div style={{
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #1a1a1f 0%, #0a0a0c 100%)',
}} />
}
/>
<div style={styles.heroOverlay} />
<div style={styles.heroContent}>
<div style={styles.heroNameRow}>
<h1 style={styles.heroName}>{profile.imaginaryName}</h1>
<div style={styles.badgeRow}>
{profile.isMajestic && (
<div style={styles.majesticBadge} title="Majestic Member">
<svg viewBox="0 0 24 24" fill="#ffffff" style={{ width: '18px', height: '18px' }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
)}
{profile.verificationStatus && (
<div style={styles.verifiedBadge} title="Verified">
<svg viewBox="0 0 24 24" fill="#ffffff" style={{ width: '18px', height: '18px' }}>
<path fillRule="evenodd" d="M8.603 3.799A4.49 4.49 0 0112 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 013.498 1.307 4.491 4.491 0 011.307 3.497A4.49 4.49 0 0121.75 12a4.49 4.49 0 01-1.549 3.397 4.491 4.491 0 01-1.307 3.497 4.491 4.491 0 01-3.497 1.307A4.49 4.49 0 0112 21.75a4.49 4.49 0 01-3.397-1.549 4.49 4.49 0 01-3.498-1.306 4.491 4.491 0 01-1.307-3.498A4.49 4.49 0 012.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 011.307-3.497 4.49 4.49 0 013.497-1.307zm7.007 6.387a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
</div>
<div style={styles.heroMeta}>
<span style={{ fontWeight: 600 }}>{profile.age}</span>
<div style={styles.metaDot} />
<span>{typeof profile.gender === 'string' ? profile.gender : ''}</span>
<div style={styles.metaDot} />
<span>{typeof profile.sexuality === 'string' ? profile.sexuality : ''}</span>
</div>
</div>
</div>
{/* Content Grid */}
<div style={styles.contentGrid}>
{/* Bio */}
<div style={styles.card}>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>About</h3>
</div>
{isEditing ? (
<div>
<label style={styles.editLabel}>Bio</label>
<textarea
style={styles.editTextarea}
value={editForm.bio}
onChange={(e) => setEditForm(prev => ({ ...prev, bio: e.target.value }))}
placeholder="Tell others about yourself..."
/>
<p style={styles.editHint}>Write something that reflects your personality</p>
</div>
) : (
<p style={styles.bioText}>{profile.bio || 'No bio yet'}</p>
)}
</div>
{/* Looking For / Connection Goals */}
<div style={styles.card}>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>Looking For (Connection Goals)</h3>
</div>
{isEditing ? (
<div>
<p style={styles.editHint}>Select what you're looking for</p>
<div style={{ ...styles.tagContainer, marginTop: '12px' }}>
{CONNECTION_GOAL_OPTIONS.map((goal) => (
<span
key={goal}
onClick={() => toggleArrayItem('connectionGoals', goal)}
style={{
...styles.tag('primary', editForm.connectionGoals.includes(goal)),
...styles.selectableTag,
}}
>
{goal.replace(/_/g, ' ')}
</span>
))}
</div>
</div>
) : (
<div style={styles.tagContainer}>
{(profile.connectionGoals && profile.connectionGoals.length > 0) ? (
profile.connectionGoals.filter((g: any) => typeof g === 'string').map((goal: string) => (
<span key={goal} style={styles.tag('primary')}>
{goal.replace(/_/g, ' ')}
</span>
))
) : (
<p style={{ ...styles.bioText, color: 'rgba(255,255,255,0.4)' }}>Not specified</p>
)}
</div>
)}
</div>
{/* I'm Looking For (Profile Types) */}
<div style={styles.card}>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>I'm Interested In</h3>
</div>
{isEditing ? (
<div>
<p style={styles.editHint}>Select who you want to see</p>
<div style={{ ...styles.tagContainer, marginTop: '12px' }}>
{LOOKING_FOR_OPTIONS.map((type) => (
<span
key={type}
onClick={() => toggleArrayItem('lookingFor', type)}
style={{
...styles.tag('interest', editForm.lookingFor.includes(type)),
...styles.selectableTag,
}}
>
{type.replace(/_/g, ' ')}
</span>
))}
</div>
</div>
) : (
<div style={styles.tagContainer}>
{(profile.lookingFor && profile.lookingFor.length > 0) ? (
profile.lookingFor.map((type: string) => (
<span key={type} style={styles.tag('interest')}>
{type.replace(/_/g, ' ')}
</span>
))
) : (
<p style={{ ...styles.bioText, color: 'rgba(255,255,255,0.4)' }}>Not specified</p>
)}
</div>
)}
</div>
{/* Desires */}
<div style={styles.card}>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>Desires</h3>
</div>
{isEditing ? (
<div>
<p style={styles.editHint}>Select your desires</p>
<div style={{ ...styles.tagContainer, marginTop: '12px' }}>
{DESIRE_OPTIONS.map((desire) => (
<span
key={desire}
onClick={() => toggleArrayItem('desires', desire)}
style={{
...styles.tag('desire', editForm.desires.includes(desire)),
...styles.selectableTag,
}}
>
{desire.replace(/_/g, ' ')}
</span>
))}
</div>
</div>
) : (
<div style={styles.tagContainer}>
{(profile.desires && profile.desires.length > 0) ? (
profile.desires.filter((d: any) => typeof d === 'string').map((desire: string) => (
<span key={desire} style={styles.tag('desire')}>
{desire.replace(/_/g, ' ')}
</span>
))
) : (
<p style={{ ...styles.bioText, color: 'rgba(255,255,255,0.4)' }}>Not specified</p>
)}
</div>
)}
</div>
{/* Interests (read-only) */}
{profile.interests && profile.interests.length > 0 && (
<div style={styles.card}>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>Interests</h3>
</div>
<div style={styles.tagContainer}>
{profile.interests.filter((i: any) => typeof i === 'string').map((interest: string) => (
<span key={interest} style={styles.tag('interest')}>
{interest}
</span>
))}
</div>
</div>
)}
{/* Photos */}
{profile.photos && profile.photos.length > 0 && (
<div style={styles.card}>
<div style={styles.photosHeader}>
<h3 style={styles.photosTitle}>Photos</h3>
<span style={styles.photosCount}>{profile.photos.length} photos</span>
</div>
<div style={styles.photosGrid}>
{profile.photos.map((photo: any) => (
<div key={photo.id} style={styles.photoItem}>
<ProxiedImage
src={getBestImageUrl(photo.pictureUrls, 'medium')}
alt=""
style={styles.photoImage}
fallback={
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<svg viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="1" style={{ width: '32px', height: '32px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
</div>
}
/>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

666
web/src/pages/SentPings.tsx Executable file
View File

@@ -0,0 +1,666 @@
import { useState } from 'react';
import { useSentPings } from '../hooks/useSentPings';
import { getBestImageUrl } from '../utils/images';
import { ProfileDetailModal } from '../components/profile/ProfileDetailModal';
// Define the enriched ping type locally to avoid import issues
interface EnrichedSentPing {
targetProfileId: string;
targetName?: string;
message?: string;
sentAt: number;
status: 'SENT' | 'MATCHED' | 'EXPIRED';
profile?: {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
bio?: string;
desires?: string[];
connectionGoals?: string[];
verificationStatus?: string;
isMajestic?: boolean;
distance?: { km: number; mi: number };
photos?: Array<{
id: string;
pictureUrls?: { small?: string; medium?: string; large?: string };
}>;
interactionStatus?: {
mine?: string;
theirs?: string;
message?: string;
};
};
profileLoading?: boolean;
profileError?: string;
}
const styles = {
container: {
paddingBottom: '48px',
},
// Header
header: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: '32px',
},
headerLeft: {
display: 'flex',
alignItems: 'center',
gap: '12px',
},
title: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '36px',
fontWeight: 700,
color: '#ffffff',
margin: 0,
letterSpacing: '-0.02em',
},
subtitle: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
color: 'rgba(255,255,255,0.5)',
margin: 0,
marginTop: '4px',
},
subtitleHighlight: {
color: '#f59e0b',
},
clearButton: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 16px',
borderRadius: '10px',
fontSize: '14px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
border: 'none',
background: 'rgba(239, 68, 68, 0.1)',
color: '#f87171',
cursor: 'pointer',
transition: 'all 0.2s ease',
},
// Ping list
pingList: {
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
},
pingItem: {
display: 'flex',
alignItems: 'flex-start',
gap: '16px',
padding: '16px 20px',
borderRadius: '16px',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
transition: 'all 0.2s ease',
cursor: 'pointer',
},
// Profile photo
photoContainer: {
position: 'relative' as const,
flexShrink: 0,
},
photo: {
width: '64px',
height: '64px',
borderRadius: '12px',
objectFit: 'cover' as const,
background: 'rgba(255,255,255,0.05)',
},
photoPlaceholder: {
width: '64px',
height: '64px',
borderRadius: '12px',
background: 'linear-gradient(135deg, rgba(245,158,11,0.2) 0%, rgba(245,158,11,0.05) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
likedBackBadge: {
position: 'absolute' as const,
bottom: '-4px',
right: '-4px',
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #be3144 0%, #c41e3a 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(190,49,68,0.4)',
border: '2px solid #0a0a0a',
},
pingContent: {
flex: 1,
minWidth: 0,
},
pingHeader: {
display: 'flex',
alignItems: 'center',
gap: '10px',
marginBottom: '4px',
flexWrap: 'wrap' as const,
},
pingName: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '16px',
fontWeight: 600,
color: '#ffffff',
},
profileDetails: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
color: 'rgba(255,255,255,0.5)',
display: 'flex',
alignItems: 'center',
gap: '8px',
},
detailDot: {
width: '3px',
height: '3px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.3)',
},
pingDate: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '13px',
color: 'rgba(255,255,255,0.4)',
marginTop: '2px',
},
pingMessage: {
marginTop: '12px',
padding: '12px 16px',
borderRadius: '12px',
background: 'rgba(0,0,0,0.3)',
border: '1px solid rgba(255,255,255,0.04)',
},
pingMessageText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
fontStyle: 'italic',
color: 'rgba(255,255,255,0.6)',
margin: 0,
lineHeight: 1.5,
},
deleteButton: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
transition: 'all 0.2s ease',
flexShrink: 0,
},
// Badge
badge: (variant: 'pending' | 'matched' | 'expired' | 'liked') => {
const colors = {
pending: { bg: 'rgba(245,158,11,0.15)', color: '#fcd34d', border: 'rgba(245,158,11,0.3)' },
matched: { bg: 'rgba(34,197,94,0.15)', color: '#86efac', border: 'rgba(34,197,94,0.3)' },
expired: { bg: 'rgba(156,163,175,0.15)', color: '#9ca3af', border: 'rgba(156,163,175,0.3)' },
liked: { bg: 'rgba(190,49,68,0.15)', color: '#f4a5b0', border: 'rgba(190,49,68,0.3)' },
};
return {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 10px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: colors[variant].bg,
color: colors[variant].color,
border: `1px solid ${colors[variant].border}`,
};
},
// Majestic badge
majesticBadge: {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: 'linear-gradient(135deg, rgba(168,85,247,0.2) 0%, rgba(139,92,246,0.2) 100%)',
color: '#c4b5fd',
border: '1px solid rgba(168,85,247,0.3)',
},
// Empty state
emptyState: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: '80px 20px',
},
emptyIcon: {
width: '80px',
height: '80px',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
},
emptyTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '22px',
fontWeight: 600,
color: '#ffffff',
marginBottom: '8px',
textAlign: 'center' as const,
},
emptyText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
color: 'rgba(255,255,255,0.5)',
textAlign: 'center' as const,
maxWidth: '280px',
},
// Modal
modalOverlay: {
position: 'fixed' as const,
inset: 0,
background: 'rgba(0,0,0,0.8)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
padding: '16px',
},
modal: {
maxWidth: '400px',
width: '100%',
padding: '24px',
borderRadius: '20px',
background: 'rgba(30,30,35,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
},
modalTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '20px',
fontWeight: 600,
color: '#ffffff',
marginBottom: '8px',
},
modalText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '14px',
color: 'rgba(255,255,255,0.6)',
marginBottom: '24px',
lineHeight: 1.5,
},
modalButtons: {
display: 'flex',
gap: '12px',
},
modalButton: (variant: 'cancel' | 'danger') => ({
flex: 1,
padding: '12px 20px',
borderRadius: '10px',
fontSize: '14px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
border: 'none',
background: variant === 'cancel' ? 'rgba(255,255,255,0.08)' : '#dc2626',
color: '#ffffff',
cursor: 'pointer',
transition: 'all 0.2s ease',
}),
// Loading
loadingContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '50vh',
},
loadingSpinner: {
width: '32px',
height: '32px',
border: '2px solid rgba(245,158,11,0.2)',
borderTopColor: '#f59e0b',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
};
// Format gender for display
const formatGender = (gender?: any) => {
if (!gender || typeof gender !== 'string') return '';
const map: Record<string, string> = {
WOMAN: 'Woman',
MAN: 'Man',
NON_BINARY: 'Non-binary',
TRANS_WOMAN: 'Trans woman',
TRANS_MAN: 'Trans man',
};
return map[gender] || gender;
};
export function SentPingsPage() {
const { sentPings, loading, profilesLoading, availablePings, removePing, clearAllPings } = useSentPings();
const [showConfirmClear, setShowConfirmClear] = useState(false);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffHours < 1) {
const diffMins = Math.floor(diffMs / (1000 * 60));
return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
} else if (diffDays < 7) {
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
} else {
return date.toLocaleDateString();
}
};
const getStatusBadge = (ping: EnrichedSentPing) => {
// Check if they liked back
const theyLikedBack = ping.profile?.interactionStatus?.theirs === 'LIKED';
if (theyLikedBack) {
return (
<span style={styles.badge('liked')}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '12px', height: '12px' }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
Liked You Back!
</span>
);
}
switch (ping.status) {
case 'MATCHED':
return (
<span style={styles.badge('matched')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '12px', height: '12px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Matched
</span>
);
case 'EXPIRED':
return (
<span style={styles.badge('expired')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '12px', height: '12px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Expired
</span>
);
case 'SENT':
default:
return (
<span style={styles.badge('pending')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '12px', height: '12px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Pending
</span>
);
}
};
const getProfilePhoto = (ping: EnrichedSentPing) => {
const photos = ping.profile?.photos;
if (photos && photos.length > 0) {
const photoUrl = getBestImageUrl(photos[0].pictureUrls, 'small');
if (photoUrl) {
return <img src={photoUrl} alt="" style={styles.photo} />;
}
}
// Placeholder
return (
<div style={styles.photoPlaceholder}>
<svg viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="1.5" style={{ width: '28px', height: '28px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
);
};
if (loading) {
return (
<div style={styles.loadingContainer}>
<div style={styles.loadingSpinner} />
</div>
);
}
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<div>
<div style={styles.headerLeft}>
<svg viewBox="0 0 24 24" fill="#f59e0b" style={{ width: '32px', height: '32px' }}>
<path d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
<h1 style={styles.title}>Sent Pings</h1>
</div>
<p style={styles.subtitle}>
{availablePings !== null && (
<span style={styles.subtitleHighlight}>{availablePings} ping{availablePings !== 1 ? 's' : ''} available</span>
)}
{profilesLoading && <span style={{ marginLeft: '12px', color: 'rgba(255,255,255,0.4)' }}>Loading profiles...</span>}
</p>
</div>
{sentPings.length > 0 && (
<button
style={styles.clearButton}
onClick={() => setShowConfirmClear(true)}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.2)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
Clear All
</button>
)}
</div>
{/* Content */}
{sentPings.length === 0 ? (
<div style={styles.emptyState}>
<div style={styles.emptyIcon}>
<svg viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="1" style={{ width: '40px', height: '40px' }}>
<path d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
</div>
<h2 style={styles.emptyTitle}>No pings sent yet</h2>
<p style={styles.emptyText}>
Send a ping to someone you're interested in to start a conversation!
</p>
</div>
) : (
<div style={styles.pingList}>
{sentPings
.sort((a, b) => b.sentAt - a.sentAt)
.map((ping) => {
const theyLikedBack = ping.profile?.interactionStatus?.theirs === 'LIKED';
return (
<div
key={ping.targetProfileId}
style={styles.pingItem}
onClick={() => setSelectedProfileId(ping.targetProfileId)}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.03)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)';
}}
>
{/* Profile Photo */}
<div style={styles.photoContainer}>
{getProfilePhoto(ping)}
{theyLikedBack && (
<div style={styles.likedBackBadge}>
<svg viewBox="0 0 24 24" fill="#fff" style={{ width: '12px', height: '12px' }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
)}
</div>
{/* Content */}
<div style={styles.pingContent}>
<div style={styles.pingHeader}>
<span style={styles.pingName}>
{ping.profile?.imaginaryName || ping.targetName || 'Unknown Profile'}
</span>
{ping.profile?.isMajestic && (
<span style={styles.majesticBadge}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '10px', height: '10px' }}>
<path fillRule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clipRule="evenodd" />
</svg>
Majestic
</span>
)}
{getStatusBadge(ping)}
</div>
{/* Profile details */}
{ping.profile && (
<div style={styles.profileDetails}>
{ping.profile.age && <span>{ping.profile.age}</span>}
{ping.profile.age && ping.profile.gender && <span style={styles.detailDot} />}
{ping.profile.gender && <span>{formatGender(ping.profile.gender)}</span>}
{ping.profile.distance && (
<>
<span style={styles.detailDot} />
<span>{Math.round(ping.profile.distance.mi)} mi</span>
</>
)}
</div>
)}
<p style={styles.pingDate}>
{formatDate(ping.sentAt)}
</p>
{ping.message && (
<div style={styles.pingMessage}>
<p style={styles.pingMessageText}>"{ping.message}"</p>
</div>
)}
</div>
{/* Delete button */}
<button
style={styles.deleteButton}
onClick={(e) => {
e.stopPropagation();
removePing(ping.targetProfileId);
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
e.currentTarget.style.color = '#f87171';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'rgba(255,255,255,0.3)';
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '18px', height: '18px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
);
})}
</div>
)}
{/* Profile Detail Modal */}
{selectedProfileId && (
<ProfileDetailModal
profileId={selectedProfileId}
onClose={() => setSelectedProfileId(null)}
/>
)}
{/* Confirm Clear Modal */}
{showConfirmClear && (
<div style={styles.modalOverlay}>
<div style={styles.modal}>
<h3 style={styles.modalTitle}>Clear All Sent Pings?</h3>
<p style={styles.modalText}>
This will remove all sent pings from your local history. This action cannot be undone.
</p>
<div style={styles.modalButtons}>
<button
style={styles.modalButton('cancel')}
onClick={() => setShowConfirmClear(false)}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.12)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
}}
>
Cancel
</button>
<button
style={styles.modalButton('danger')}
onClick={() => {
clearAllPings();
setShowConfirmClear(false);
}}
onMouseOver={(e) => {
e.currentTarget.style.background = '#b91c1c';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = '#dc2626';
}}
>
Clear All
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default SentPingsPage;

1455
web/src/pages/Settings.tsx Executable file

File diff suppressed because it is too large Load Diff

52
web/src/utils/images.ts Executable file
View File

@@ -0,0 +1,52 @@
const CLOUDINARY_HOST = 'res.cloudinary.com';
const FLDCDN_HOST = 'prod.fldcdn.com';
/**
* Transforms CDN URLs to use the local proxy to avoid 418/CORS errors.
* The proxy adds the necessary headers to bypass hotlink protection.
*/
export function getProxiedImageUrl(url: string | undefined | null): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url);
if (parsed.host === CLOUDINARY_HOST) {
// Transform: https://res.cloudinary.com/threender/...
// To: /api/images/threender/...
return `/api/images${parsed.pathname}${parsed.search}`;
}
if (parsed.host === FLDCDN_HOST) {
// Transform: https://prod.fldcdn.com/...
// To: /api/fldcdn/...
return `/api/fldcdn${parsed.pathname}${parsed.search}`;
}
} catch {
// If URL parsing fails, return original
}
return url;
}
/**
* Get the best available image URL from pictureUrls object
*/
export function getBestImageUrl(
pictureUrls: { small?: string; medium?: string; large?: string } | undefined,
preferredSize: 'small' | 'medium' | 'large' = 'medium'
): string | undefined {
if (!pictureUrls) return undefined;
const sizes: ('small' | 'medium' | 'large')[] =
preferredSize === 'large' ? ['large', 'medium', 'small'] :
preferredSize === 'medium' ? ['medium', 'large', 'small'] :
['small', 'medium', 'large'];
for (const size of sizes) {
const url = pictureUrls[size];
if (url) return getProxiedImageUrl(url);
}
return undefined;
}

28
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

121
web/vite.config.ts Executable file
View File

@@ -0,0 +1,121 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 3000,
open: true,
allowedHosts: true,
// HMR disabled - reverse proxy (feeld.treytartt.com) doesn't forward WebSockets
// Manual refresh required after code changes when using custom domain
// Access http://localhost:3000 directly for HMR during development
hmr: false,
proxy: {
'/api/graphql': {
target: 'https://core.api.fldcore.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/graphql/, '/graphql'),
secure: false,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
// Remove browser headers that reveal this is a web client
proxyReq.removeHeader('origin');
proxyReq.removeHeader('referer');
// Ensure mobile app headers are preserved
if (!proxyReq.getHeader('user-agent')?.includes('feeld')) {
proxyReq.setHeader('User-Agent', 'feeld-mobile');
}
});
},
},
'/api/firebase': {
target: 'https://securetoken.googleapis.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/firebase/, ''),
secure: false,
},
'/api/images': {
target: 'https://res.cloudinary.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/images/, ''),
secure: false,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
// Remove browser headers that trigger hotlink protection
proxyReq.removeHeader('origin');
proxyReq.removeHeader('referer');
// Set mobile app headers
proxyReq.setHeader('User-Agent', 'feeld-mobile');
proxyReq.setHeader('Accept', '*/*');
});
},
},
'/api/fldcdn': {
target: 'https://prod.fldcdn.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/fldcdn/, ''),
secure: false,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
// Remove browser headers that trigger hotlink protection
proxyReq.removeHeader('origin');
proxyReq.removeHeader('referer');
proxyReq.removeHeader('sec-fetch-dest');
proxyReq.removeHeader('sec-fetch-mode');
proxyReq.removeHeader('sec-fetch-site');
proxyReq.removeHeader('sec-ch-ua');
proxyReq.removeHeader('sec-ch-ua-mobile');
proxyReq.removeHeader('sec-ch-ua-platform');
// Set mobile app headers to match iOS app
// APP_VERSION: keep in sync with src/config/constants.ts APP_VERSION
proxyReq.setHeader('User-Agent', 'Feeld/8.8.3 (com.3nder.ios; build:1; iOS 18.6.2) Alamofire/5.9.1');
proxyReq.setHeader('Accept', '*/*');
proxyReq.setHeader('Accept-Language', 'en-US,en;q=0.9');
proxyReq.setHeader('x-app-version', '8.8.3');
proxyReq.setHeader('x-device-os', 'ios');
proxyReq.setHeader('x-os-version', '18.6.2');
});
},
},
// Local backend endpoints (must be last to not override specific proxies above)
'/api/who-liked-you': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/sent-pings': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/disliked-profiles': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/discovered-profiles': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/location-rotation': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/saved-locations': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/data': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/auth': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/health': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})