Initial commit
This commit is contained in:
7
web/.dockerignore
Normal file
7
web/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.DS_Store
|
||||
server/data
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal 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
26
web/Dockerfile
Normal 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
73
web/README.md
Normal 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
4
web/data/auth.json
Executable file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "feeld123"
|
||||
}
|
||||
339
web/data/dislikedProfiles.json
Normal file
339
web/data/dislikedProfiles.json
Normal 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
12
web/data/sentPings.json
Executable 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
189
web/data/user.json
Executable 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
1534
web/data/whoLikedYou.json
Executable file
File diff suppressed because it is too large
Load Diff
57
web/docker-compose.local.yml
Normal file
57
web/docker-compose.local.yml
Normal 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
64
web/docker-compose.yml
Normal 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
16
web/docker-entrypoint.sh
Normal 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
23
web/eslint.config.js
Normal 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
13
web/index.html
Normal 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
119
web/nginx.conf
Executable 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
7318
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
web/package.json
Normal file
46
web/package.json
Normal 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
1
web/public/vite.svg
Normal 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
22
web/server/Dockerfile
Executable 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
1040
web/server/index.js
Executable file
File diff suppressed because it is too large
Load Diff
197
web/src/App.tsx
Executable file
197
web/src/App.tsx
Executable 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 & 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
151
web/src/api/auth.ts
Executable 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
82
web/src/api/client.ts
Executable 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
561
web/src/api/dataSync.ts
Executable 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;
|
||||
300
web/src/api/operations/experimental.ts
Executable file
300
web/src/api/operations/experimental.ts
Executable 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 },
|
||||
];
|
||||
296
web/src/api/operations/mutations.ts
Executable file
296
web/src/api/operations/mutations.ts
Executable 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
579
web/src/api/operations/queries.ts
Executable 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
162
web/src/components/LoginPage.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
132
web/src/components/chat/ChatListItem.tsx
Executable file
132
web/src/components/chat/ChatListItem.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
32
web/src/components/layout/Layout.tsx
Executable file
32
web/src/components/layout/Layout.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
184
web/src/components/layout/Navigation.tsx
Executable file
184
web/src/components/layout/Navigation.tsx
Executable 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
247
web/src/components/profile/PingModal.tsx
Executable file
247
web/src/components/profile/PingModal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
483
web/src/components/profile/ProfileCard.tsx
Executable file
483
web/src/components/profile/ProfileCard.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
903
web/src/components/profile/ProfileDetailModal.tsx
Executable file
903
web/src/components/profile/ProfileDetailModal.tsx
Executable 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
116
web/src/components/ui/Avatar.tsx
Executable 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
71
web/src/components/ui/Badge.tsx
Executable 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
104
web/src/components/ui/Button.tsx
Executable 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
68
web/src/components/ui/Card.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
85
web/src/components/ui/Loading.tsx
Executable file
85
web/src/components/ui/Loading.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
87
web/src/components/ui/ProxiedImage.tsx
Executable file
87
web/src/components/ui/ProxiedImage.tsx
Executable 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
61
web/src/config/constants.ts
Executable 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; },
|
||||
};
|
||||
211
web/src/context/StreamChatContext.tsx
Executable file
211
web/src/context/StreamChatContext.tsx
Executable 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
100
web/src/hooks/useAuth.tsx
Executable 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;
|
||||
}
|
||||
121
web/src/hooks/useDislikedProfiles.ts
Normal file
121
web/src/hooks/useDislikedProfiles.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
93
web/src/hooks/useLikedProfiles.ts
Executable file
93
web/src/hooks/useLikedProfiles.ts
Executable 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
199
web/src/hooks/useLocation.tsx
Executable 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
237
web/src/hooks/useSentPings.ts
Executable 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
339
web/src/index.css
Executable 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
10
web/src/main.tsx
Executable 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
693
web/src/pages/ApiExplorer.tsx
Executable 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
504
web/src/pages/Chat.tsx
Executable 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
737
web/src/pages/Discover.tsx
Executable 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
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
111
web/src/pages/Messages.tsx
Executable 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
904
web/src/pages/Profile.tsx
Executable 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
666
web/src/pages/SentPings.tsx
Executable 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
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
52
web/src/utils/images.ts
Executable 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
28
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
121
web/vite.config.ts
Executable 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user