mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-05 07:14:02 -04:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164eddecab | ||
|
|
9eacb38eb9 | ||
|
|
20f5cfb9a7 | ||
|
|
6c6c1cc90a | ||
|
|
a32c099cc1 | ||
|
|
fe2f832e83 | ||
|
|
868746cc23 | ||
|
|
3be7a54284 | ||
|
|
635e1ec8e2 | ||
|
|
a638a35a76 | ||
|
|
8cc33d3418 | ||
|
|
9947f7b967 | ||
|
|
daf5350f41 | ||
|
|
020b9ddb8d | ||
|
|
23aff9497a | ||
|
|
3c119396f3 | ||
|
|
f7c7c47ac0 | ||
|
|
dbe2369bbe | ||
|
|
4e8033d221 | ||
|
|
97a0f87cbd | ||
|
|
bfa2713d43 | ||
|
|
fe5e109751 | ||
|
|
8cc96030b1 | ||
|
|
a2b172ad58 | ||
|
|
e756225d8b | ||
|
|
dd803b604f | ||
|
|
b5c961c8ee | ||
|
|
47cd9d227e | ||
|
|
e2be3aafcd | ||
|
|
015fe76c44 | ||
|
|
44666aec03 | ||
|
|
6a265e4f35 | ||
|
|
12c7316524 | ||
|
|
dcf9741d69 | ||
|
|
63dd1fdd50 | ||
|
|
5aa166bbfd | ||
|
|
34cbf7093e | ||
|
|
159d58949e | ||
|
|
fcf802b7e3 | ||
|
|
92ff6dadb0 | ||
|
|
05fa2f9883 | ||
|
|
71bb8fd784 | ||
|
|
16ffd6dfab | ||
|
|
2661d15910 | ||
|
|
394102bb93 | ||
|
|
3585b12dfd | ||
|
|
423d87d5f1 | ||
|
|
13b13b1104 | ||
|
|
a77e7b96b7 | ||
|
|
d7213c255c | ||
|
|
ddeb1dcdb7 | ||
|
|
221cfa3528 | ||
|
|
d6f6348ff1 | ||
|
|
0c6afdc98e | ||
|
|
02a2148b3f | ||
|
|
36a02268d8 | ||
|
|
450f07f505 | ||
|
|
777eba9fed | ||
|
|
eaa8fa57d1 | ||
|
|
200bf479e1 | ||
|
|
331f409af9 | ||
|
|
ce875a5e63 | ||
|
|
638013f835 | ||
|
|
1de87cbfec | ||
|
|
7f3428b36a | ||
|
|
35595ded47 | ||
|
|
35e9264017 | ||
|
|
02d33c8f83 | ||
|
|
f229ebc3a8 | ||
|
|
0062351f6d | ||
|
|
e86f6798ec | ||
|
|
4f53f7136b | ||
|
|
d80b982dde | ||
|
|
24788aa9af | ||
|
|
9ffae658df | ||
|
|
82ad573cac | ||
|
|
36bf7ad65b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,4 +84,5 @@ email-preview
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.terraform
|
||||
/backups/firebase/auth/data/
|
||||
/backups/firebase/storage/data/
|
||||
|
||||
23
README.md
23
README.md
@@ -21,11 +21,28 @@ This repository contains the source code for [Compass](https://compassmeet.com)
|
||||
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
|
||||
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
|
||||
|
||||
<p style="text-align: center;">
|
||||
<img src="https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fdemo_compass.gif?alt=media&token=e3ae4334-4e3f-4026-b121-c08b4b724cd1" alt="Compass Demo" width="600">
|
||||
</p>
|
||||
|
||||
## To Do
|
||||
|
||||
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording. Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The goal is to make the platform better step by step, and every improvement counts. If you see something that could be clearer, smoother, or more engaging, **please jump in**!
|
||||
|
||||
Here are some examples of things that would be very useful. If you want to help but don’t know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
||||
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
|
||||
|
||||
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, just use your preferred option:
|
||||
- Ask or DM an admin on Discord
|
||||
- Email hello@compassmeet.com
|
||||
- Raise an issue on GitHub
|
||||
|
||||
If you want to add tasks without creating an account, you can simply email
|
||||
```
|
||||
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
|
||||
```
|
||||
Put the task title in the email subject and the task description in the email content.
|
||||
|
||||
Here is a tailored selection of things that would be very useful. If you want to help but don’t know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
||||
|
||||
- [x] Authentication (user/password and Google Sign In)
|
||||
- [x] Set up PostgreSQL in Production with supabase
|
||||
@@ -58,7 +75,7 @@ Everything is open to anyone for collaboration, but the following ones are parti
|
||||
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
||||
- [ ] Add email verification
|
||||
- [ ] Add password reset
|
||||
- [ ] Add automated welcome email
|
||||
- [x] Add automated welcome email
|
||||
- [ ] Security audit and penetration testing
|
||||
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||
- [ ] Create settings page (change email, password, delete account, etc.)
|
||||
@@ -105,7 +122,7 @@ Almost all the features will work out of the box, so you can skip this step and
|
||||
We can't make the following information public, for security and privacy reasons:
|
||||
- Database, otherwise anyone could access all the user data (including private messages)
|
||||
- Firebase, otherwise anyone could remove users or modify the media files
|
||||
- Email, analytics, and location services, otherwise anyone could use our paid plan
|
||||
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
|
||||
|
||||
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
|
||||
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
|
||||
|
||||
@@ -54,6 +54,9 @@ gcloud projects add-iam-policy-binding compass-130ba \
|
||||
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
|
||||
--role="roles/secretmanager.secretAccessor"
|
||||
gcloud run services list
|
||||
gcloud compute backend-services update api-backend \
|
||||
--global \
|
||||
--timeout=600s
|
||||
```
|
||||
|
||||
Set up the saved search notifications job:
|
||||
|
||||
@@ -185,29 +185,29 @@ resource "google_compute_url_map" "api_url_map" {
|
||||
path_matcher {
|
||||
name = "allpaths"
|
||||
default_service = google_compute_backend_service.api_backend.self_link
|
||||
|
||||
# Priority 0: passthrough /v0/* requests
|
||||
route_rules {
|
||||
priority = 1
|
||||
match_rules {
|
||||
prefix_match = "/v0"
|
||||
}
|
||||
service = google_compute_backend_service.api_backend.self_link
|
||||
}
|
||||
|
||||
# Priority 1: rewrite everything else to /v0
|
||||
route_rules {
|
||||
priority = 2
|
||||
match_rules {
|
||||
prefix_match = "/"
|
||||
}
|
||||
route_action {
|
||||
url_rewrite {
|
||||
path_prefix_rewrite = "/v0/"
|
||||
}
|
||||
}
|
||||
service = google_compute_backend_service.api_backend.self_link
|
||||
}
|
||||
#
|
||||
# # Priority 0: passthrough /v0/* requests
|
||||
# route_rules {
|
||||
# priority = 1
|
||||
# match_rules {
|
||||
# prefix_match = "/v0"
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
#
|
||||
# # Priority 1: rewrite everything else to /v0
|
||||
# route_rules {
|
||||
# priority = 2
|
||||
# match_rules {
|
||||
# prefix_match = "/"
|
||||
# }
|
||||
# route_action {
|
||||
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
|
||||
# path_prefix_rewrite = "/v0/"
|
||||
# }
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ swaggerDocument.info = {
|
||||
version: "1.0.0",
|
||||
contact: {
|
||||
name: "Compass",
|
||||
email: "compass.meet.info@gmail.com",
|
||||
email: "hello@compassmeet.com",
|
||||
url: "https://compassmeet.com"
|
||||
}
|
||||
};
|
||||
@@ -191,7 +191,7 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
||||
}
|
||||
})
|
||||
|
||||
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
||||
// console.debug('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
||||
|
||||
// Internal Endpoints
|
||||
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
|
||||
@@ -9,6 +9,30 @@ import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
|
||||
function extractTextFromBio(bio: any): string {
|
||||
try {
|
||||
const texts: string[] = []
|
||||
const visit = (node: any) => {
|
||||
if (!node) return
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) visit(item)
|
||||
return
|
||||
}
|
||||
if (typeof node === 'object') {
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
if (k === 'text' && typeof v === 'string') texts.push(v)
|
||||
else visit(v as any)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(bio)
|
||||
// Remove extra whitespace and join
|
||||
return texts.map((t) => t.trim()).filter(Boolean).join(' ')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
@@ -29,7 +53,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
||||
}
|
||||
|
||||
console.log('body', body)
|
||||
console.debug('body', body)
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||
@@ -46,15 +70,17 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} catch (e) {
|
||||
console.log('Failed to track create profile', e)
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
await sendDiscordMessage(
|
||||
`**${user.name}** just created a profile at https://www.compassmeet.com/${user.username}`,
|
||||
'members',
|
||||
)
|
||||
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
|
||||
if (body.bio) {
|
||||
const bioText = extractTextFromBio(body.bio)
|
||||
if (bioText) message += `\n > ${bioText}`
|
||||
}
|
||||
await sendDiscordMessage(message, 'members')
|
||||
} catch (e) {
|
||||
console.log('Failed to send discord new profile', e)
|
||||
console.error('Failed to send discord new profile', e)
|
||||
}
|
||||
try {
|
||||
const nProfiles = await pg.one<number>(
|
||||
@@ -69,7 +95,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
n % 50 === 0
|
||||
)
|
||||
}
|
||||
console.log(nProfiles, isMilestone(nProfiles))
|
||||
console.debug(nProfiles, isMilestone(nProfiles))
|
||||
if (isMilestone(nProfiles)) {
|
||||
await sendDiscordMessage(
|
||||
`We just reached **${nProfiles}** total profiles! 🎉`,
|
||||
@@ -78,7 +104,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log('Failed to send discord user milestone', e)
|
||||
console.error('Failed to send discord user milestone', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||
import {removeUndefinedProps} from 'common/util/object'
|
||||
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
|
||||
import {RESERVED_PATHS} from 'common/envs/constants'
|
||||
import {IS_LOCAL, RESERVED_PATHS} from 'common/envs/constants'
|
||||
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||
import {getBucket} from "shared/firebase-utils";
|
||||
import {sendWelcomeEmail} from "email/functions/helpers";
|
||||
|
||||
export const createUser: APIHandler<'create-user'> = async (
|
||||
props,
|
||||
@@ -126,7 +127,12 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} catch (e) {
|
||||
console.log('Failed to track create profile', e)
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
if (!IS_LOCAL) await sendWelcomeEmail(user, privateUser)
|
||||
} catch (e) {
|
||||
console.error('Failed to sendWelcomeEmail', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
try {
|
||||
const auth = admin.auth()
|
||||
await auth.deleteUser(userId)
|
||||
console.log(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||
} catch (e) {
|
||||
console.error('Error deleting user from Firebase Auth:', e)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const getCompatibilityQuestions: APIHandler<
|
||||
|
||||
const questions = shuffle(dbQuestions)
|
||||
|
||||
// console.log(
|
||||
// console.debug(
|
||||
// 'got questions',
|
||||
// questions.map((q) => q.question + ' ' + q.score)
|
||||
// )
|
||||
|
||||
@@ -4,7 +4,7 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {getCompatibleProfiles} from 'api/compatible-profiles'
|
||||
import {intersection} from 'lodash'
|
||||
import {MAX_INT, MIN_INT} from "common/constants";
|
||||
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
|
||||
|
||||
export type profileQueryType = {
|
||||
limit?: number | undefined,
|
||||
@@ -19,6 +19,7 @@ export type profileQueryType = {
|
||||
wants_kids_strength?: number | undefined,
|
||||
has_kids?: number | undefined,
|
||||
is_smoker?: boolean | undefined,
|
||||
shortBio?: boolean | undefined,
|
||||
geodbCityIds?: String[] | undefined,
|
||||
compatibleWithUserId?: string | undefined,
|
||||
skipId?: string | undefined,
|
||||
@@ -29,7 +30,7 @@ export type profileQueryType = {
|
||||
|
||||
export const loadProfiles = async (props: profileQueryType) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
console.log(props)
|
||||
console.debug(props)
|
||||
const {
|
||||
limit: limitParam,
|
||||
after,
|
||||
@@ -42,6 +43,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
wants_kids_strength,
|
||||
has_kids,
|
||||
is_smoker,
|
||||
shortBio,
|
||||
geodbCityIds,
|
||||
compatibleWithUserId,
|
||||
orderBy: orderByParam = 'created_time',
|
||||
@@ -81,13 +83,14 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(!is_smoker || l.is_smoker === is_smoker) &&
|
||||
(l.id.toString() != skipId) &&
|
||||
(!geodbCityIds ||
|
||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
|
||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
|
||||
((l.bio_length ?? 0) >= MIN_BIO_LENGTH)
|
||||
)
|
||||
|
||||
const cursor = after
|
||||
? profiles.findIndex((l) => l.id.toString() === after) + 1
|
||||
: 0
|
||||
console.log(cursor)
|
||||
console.debug(cursor)
|
||||
|
||||
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
|
||||
|
||||
@@ -106,7 +109,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
||||
|
||||
...keywords.map(word => where(
|
||||
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%'`,
|
||||
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
|
||||
{word}
|
||||
)),
|
||||
|
||||
@@ -153,12 +156,14 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
{after}
|
||||
),
|
||||
|
||||
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
|
||||
|
||||
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
||||
|
||||
limitParam && limit(limitParam)
|
||||
)
|
||||
|
||||
// console.log('query:', query)
|
||||
// console.debug('query:', query)
|
||||
|
||||
return await pg.map(query, [], convertRow)
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
// TODO: notification only for active user
|
||||
|
||||
const otherUser = await getUser(otherUserId.user_id)
|
||||
console.log('otherUser:', otherUser)
|
||||
console.debug('otherUser:', otherUser)
|
||||
if (!otherUser) return
|
||||
|
||||
await createNewMessageNotification(creator, otherUser, channelId)
|
||||
@@ -175,7 +175,7 @@ const createNewMessageNotification = async (
|
||||
channelId: number
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
console.log('privateUser:', privateUser)
|
||||
console.debug('privateUser:', privateUser)
|
||||
if (!privateUser) return
|
||||
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ import {geodbFetch} from "common/geodb";
|
||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||
const {term, limit} = body
|
||||
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
|
||||
// const endpoint = `/countries?namePrefix=${term}&limit=${limit ?? 10}&offset=0`
|
||||
return await geodbFetch(endpoint)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ import {APIHandler} from './helpers/endpoint'
|
||||
import {geodbFetch} from "common/geodb";
|
||||
|
||||
const searchNearCityMain = async (cityId: string, radius: number) => {
|
||||
// Limit to 10 cities for now for free plan, was 100 before (may need to buy plan)
|
||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=10`
|
||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
|
||||
return await geodbFetch(endpoint)
|
||||
}
|
||||
|
||||
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
||||
const { cityId, radius } = body
|
||||
const {cityId, radius} = body
|
||||
return await searchNearCityMain(cityId, radius)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export const sendSearchNotifications = async () => {
|
||||
from('bookmarked_searches'),
|
||||
)
|
||||
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
|
||||
console.log(`Running ${searches.length} bookmarked searches`)
|
||||
console.debug(`Running ${searches.length} bookmarked searches`)
|
||||
|
||||
const _users = await pg.map(
|
||||
renderSql(
|
||||
@@ -36,7 +36,7 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow
|
||||
) as Row<'users'>[]
|
||||
const users = keyBy(_users, 'id')
|
||||
console.log('users', users)
|
||||
console.debug('users', users)
|
||||
|
||||
const _privateUsers = await pg.map(
|
||||
renderSql(
|
||||
@@ -47,15 +47,20 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow
|
||||
) as Row<'private_users'>[]
|
||||
const privateUsers = keyBy(_privateUsers, 'id')
|
||||
console.log('privateUsers', privateUsers)
|
||||
console.debug('privateUsers', privateUsers)
|
||||
|
||||
const matches: MatchesByUserType = {}
|
||||
|
||||
for (const row of searches) {
|
||||
if (typeof row.search_filters !== 'object') continue;
|
||||
const props = {...row.search_filters, skipId: row.creator_id, lastModificationWithin: '24 hours'}
|
||||
const props = {
|
||||
...row.search_filters,
|
||||
skipId: row.creator_id,
|
||||
lastModificationWithin: '24 hours',
|
||||
shortBio: true,
|
||||
}
|
||||
const profiles = await loadProfiles(props as profileQueryType)
|
||||
console.log(profiles.map((item: any) => item.name))
|
||||
console.debug(profiles.map((item: any) => item.name))
|
||||
if (!profiles.length) continue
|
||||
if (!(row.creator_id in matches)) {
|
||||
if (!privateUsers[row.creator_id]) continue
|
||||
@@ -74,7 +79,7 @@ export const sendSearchNotifications = async () => {
|
||||
})),
|
||||
})
|
||||
}
|
||||
console.log('matches:', JSON.stringify(matches, null, 2))
|
||||
console.debug('matches:', JSON.stringify(matches, null, 2))
|
||||
await notifyBookmarkedSearch(matches)
|
||||
|
||||
return {status: 'success'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {PrivateUser, User} from 'common/user'
|
||||
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
|
||||
import {getNotificationDestinationsForUser, UNSUBSCRIBE_URL} from 'common/user-notification-preferences'
|
||||
import {sendEmail} from './send-email'
|
||||
import {NewMessageEmail} from '../new-message'
|
||||
import {NewEndorsementEmail} from '../new-endorsement'
|
||||
@@ -8,6 +8,7 @@ import {getProfile} from 'shared/love/supabase'
|
||||
import { render } from "@react-email/render"
|
||||
import {MatchesType} from "common/love/bookmarked_searches";
|
||||
import NewSearchAlertsEmail from "email/new-search_alerts";
|
||||
import WelcomeEmail from "email/welcome";
|
||||
|
||||
const from = 'Compass <no-reply@compassmeet.com>'
|
||||
|
||||
@@ -75,6 +76,25 @@ export const sendNewMessageEmail = async (
|
||||
})
|
||||
}
|
||||
|
||||
export const sendWelcomeEmail = async (
|
||||
toUser: User,
|
||||
privateUser: PrivateUser,
|
||||
) => {
|
||||
if (!privateUser.email) return
|
||||
return await sendEmail({
|
||||
from,
|
||||
subject: `Welcome to Compass!`,
|
||||
to: privateUser.email,
|
||||
html: await render(
|
||||
<WelcomeEmail
|
||||
toUser={toUser}
|
||||
unsubscribeUrl={UNSUBSCRIBE_URL}
|
||||
email={privateUser.email}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export const sendSearchAlertsEmail = async (
|
||||
toUser: User,
|
||||
privateUser: PrivateUser,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ProfileRow } from 'common/love/profile'
|
||||
import type { User } from 'common/user'
|
||||
import {ProfileRow} from 'common/love/profile'
|
||||
import type {User} from 'common/user'
|
||||
|
||||
// for email template testing
|
||||
|
||||
export const sinclairUser: User = {
|
||||
export const mockUser: User = {
|
||||
createdTime: 0,
|
||||
bio: 'the futa in futarchy',
|
||||
website: 'sincl.ai',
|
||||
@@ -78,6 +78,7 @@ export const sinclairProfile: ProfileRow = {
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio_length: 1000,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -101,6 +102,8 @@ export const sinclairProfile: ProfileRow = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
age: 25,
|
||||
}
|
||||
|
||||
@@ -173,6 +176,7 @@ export const jamesProfile: ProfileRow = {
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio_length: 1000,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -202,5 +206,7 @@ export const jamesProfile: ProfileRow = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
age: 32,
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const sendEmail = async (
|
||||
options?: CreateEmailRequestOptions
|
||||
) => {
|
||||
const resend = getResend()
|
||||
console.log(resend, payload, options)
|
||||
console.debug(resend, payload, options)
|
||||
|
||||
async function sendEmailThrottle(data: any, options: any) {
|
||||
if (!resend) return { data: null, error: 'No Resend client' }
|
||||
@@ -28,7 +28,7 @@ export const sendEmail = async (
|
||||
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
|
||||
options
|
||||
)
|
||||
console.log('resend.emails.send', data, error)
|
||||
console.debug('resend.emails.send', data, error)
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
@@ -47,12 +47,12 @@ const getResend = () => {
|
||||
if (resend) return resend
|
||||
|
||||
if (!process.env.RESEND_KEY) {
|
||||
console.log('No RESEND_KEY, skipping email send')
|
||||
console.debug('No RESEND_KEY, skipping email send')
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = process.env.RESEND_KEY as string
|
||||
// console.log(`RESEND_KEY: ${apiKey}`)
|
||||
// console.debug(`RESEND_KEY: ${apiKey}`)
|
||||
resend = new Resend(apiKey)
|
||||
return resend
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ if (require.main === module) {
|
||||
const email = process.argv[2]
|
||||
if (!email) {
|
||||
console.error('Please provide an email address')
|
||||
console.log('Usage: ts-node send-test-email.ts your@email.com')
|
||||
console.debug('Usage: ts-node send-test-email.ts your@email.com')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
sendTestEmail(email)
|
||||
.then(() => console.log('Email sent successfully!'))
|
||||
.then(() => console.debug('Email sent successfully!'))
|
||||
.catch((error) => console.error('Failed to send email:', error))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Body, Button, Column, Container, Head, Html, Preview, Row, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {jamesUser, sinclairUser} from './functions/mock'
|
||||
import {jamesUser, mockUser} from './functions/mock'
|
||||
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
||||
|
||||
interface NewEndorsementEmailProps {
|
||||
@@ -74,7 +74,7 @@ export const NewEndorsementEmail = ({
|
||||
|
||||
NewEndorsementEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
onUser: sinclairUser,
|
||||
onUser: mockUser,
|
||||
endorsementText:
|
||||
"Sinclair is someone you want to have around because she injects creativity and humor into every conversation, and her laugh is infectious! Not to mention that she's a great employee, treats everyone with respect, and is even-tempered.",
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@rea
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {type ProfileRow} from 'common/love/profile'
|
||||
import {type User} from 'common/user'
|
||||
import {jamesProfile, jamesUser, sinclairUser} from './functions/mock'
|
||||
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
|
||||
import {Footer} from "email/utils";
|
||||
|
||||
interface NewMatchEmailProps {
|
||||
@@ -70,7 +70,7 @@ export const NewMatchEmail = ({
|
||||
}
|
||||
|
||||
NewMatchEmail.PreviewProps = {
|
||||
onUser: sinclairUser,
|
||||
onUser: mockUser,
|
||||
matchedWithUser: jamesUser,
|
||||
matchedProfile: jamesProfile,
|
||||
email: 'someone@gmail.com',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {type ProfileRow} from 'common/love/profile'
|
||||
import {jamesProfile, jamesUser, sinclairUser,} from './functions/mock'
|
||||
import {jamesProfile, jamesUser, mockUser,} from './functions/mock'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
|
||||
|
||||
@@ -74,7 +74,7 @@ export const NewMessageEmail = ({
|
||||
NewMessageEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
fromUserProfile: jamesProfile,
|
||||
toUser: sinclairUser,
|
||||
toUser: mockUser,
|
||||
channelId: 1,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Body, Container, Head, Html, Link, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {sinclairUser,} from './functions/mock'
|
||||
import {mockUser,} from './functions/mock'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {container, content, Footer, main, paragraph} from "email/utils";
|
||||
import {MatchesType} from "common/love/bookmarked_searches";
|
||||
@@ -140,7 +140,7 @@ const matchSamples = [
|
||||
]
|
||||
|
||||
NewSearchAlertsEmail.PreviewProps = {
|
||||
toUser: sinclairUser,
|
||||
toUser: mockUser,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
matches: matchSamples,
|
||||
|
||||
82
backend/email/emails/welcome.tsx
Normal file
82
backend/email/emails/welcome.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {mockUser,} from './functions/mock'
|
||||
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
||||
|
||||
function randomHex(length: number) {
|
||||
const bytes = new Uint8Array(Math.ceil(length / 2));
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, length);
|
||||
}
|
||||
|
||||
interface WelcomeEmailProps {
|
||||
toUser: User
|
||||
unsubscribeUrl: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export const WelcomeEmail = ({
|
||||
toUser,
|
||||
unsubscribeUrl,
|
||||
email,
|
||||
}: WelcomeEmailProps) => {
|
||||
const name = toUser.name.split(' ')[0]
|
||||
const confirmUrl = `https://compassmeet.com/confirm-email/${randomHex(16)}`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head/>
|
||||
<Preview>Welcome to Compass — Please confirm your email</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Welcome to Compass, {name}!</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
Compass is a free, community-owned platform built to help people form
|
||||
deep, meaningful connections — platonic, romantic, or collaborative.
|
||||
There are no ads, no hidden algorithms, and no subscriptions — just a
|
||||
transparent, open-source space shaped by people like you.
|
||||
</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
To finish creating your account and start exploring Compass, please
|
||||
confirm your email below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
style={button}
|
||||
href={confirmUrl}
|
||||
>
|
||||
Confirm My Email
|
||||
</Button>
|
||||
|
||||
<Text style={{marginTop: "40px", fontSize: "10px", color: "#555"}}>
|
||||
Or copy and paste this link into your browser: <br/>
|
||||
<a href={confirmUrl}>{confirmUrl}</a>
|
||||
</Text>
|
||||
|
||||
<Text style={{marginTop: "40px", fontSize: "12px", color: "#555"}}>
|
||||
Your presence and participation are what make Compass possible. Thank you
|
||||
for helping us build an internet space that prioritizes depth, trust, and
|
||||
community over monetization.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
WelcomeEmail.PreviewProps = {
|
||||
toUser: mockUser,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
} as WelcomeEmailProps
|
||||
|
||||
|
||||
export default WelcomeEmail
|
||||
@@ -5,7 +5,7 @@
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
{
|
||||
"bucket": "compass-130ba-private.firebasestorage.app",
|
||||
"bucket": "compass-130ba-private",
|
||||
"rules": "private-storage.rules"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,4 +7,4 @@ service firebase.storage {
|
||||
allow write: if request.auth.uid == userId && request.resource.size <= 20 * 1024 * 1024; // 20MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{allPaths=**} {
|
||||
allow read;
|
||||
// Don't require auth, as dream uploads can be done by anyone
|
||||
allow write: if request.resource.size <= 10 * 1024 * 1024; // 10MB
|
||||
allow write: if request.auth != null && request.resource.size <= 10 * 1024 * 1024;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ runScript(async ({ pg }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// console.log('updates', updates.slice(0, 10))
|
||||
// console.debug('updates', updates.slice(0, 10))
|
||||
// return
|
||||
|
||||
let count = 0
|
||||
|
||||
@@ -24,7 +24,7 @@ runScript(async ({ pg }) => {
|
||||
})
|
||||
|
||||
const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
console.log(`\nSearching comments for ${nodeName}...`)
|
||||
console.debug(`\nSearching comments for ${nodeName}...`)
|
||||
const commentQuery = renderSql(
|
||||
select('id, user_id, on_user_id, content'),
|
||||
from('profile_comments'),
|
||||
@@ -32,15 +32,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
const comments = await pg.manyOrNone(commentQuery)
|
||||
|
||||
console.log(`Found ${comments.length} comments:`)
|
||||
console.debug(`Found ${comments.length} comments:`)
|
||||
comments.forEach((comment) => {
|
||||
console.log('\nComment ID:', comment.id)
|
||||
console.log('From user:', comment.user_id)
|
||||
console.log('On user:', comment.on_user_id)
|
||||
console.log('Content:', JSON.stringify(comment.content))
|
||||
console.debug('\nComment ID:', comment.id)
|
||||
console.debug('From user:', comment.user_id)
|
||||
console.debug('On user:', comment.on_user_id)
|
||||
console.debug('Content:', JSON.stringify(comment.content))
|
||||
})
|
||||
|
||||
console.log(`\nSearching private messages for ${nodeName}...`)
|
||||
console.debug(`\nSearching private messages for ${nodeName}...`)
|
||||
const messageQuery = renderSql(
|
||||
select('id, user_id, channel_id, content'),
|
||||
from('private_user_messages'),
|
||||
@@ -48,15 +48,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
const messages = await pg.manyOrNone(messageQuery)
|
||||
|
||||
console.log(`Found ${messages.length} private messages:`)
|
||||
console.debug(`Found ${messages.length} private messages:`)
|
||||
messages.forEach((msg) => {
|
||||
console.log('\nMessage ID:', msg.id)
|
||||
console.log('From user:', msg.user_id)
|
||||
console.log('Channel:', msg.channel_id)
|
||||
console.log('Content:', JSON.stringify(msg.content))
|
||||
console.debug('\nMessage ID:', msg.id)
|
||||
console.debug('From user:', msg.user_id)
|
||||
console.debug('Channel:', msg.channel_id)
|
||||
console.debug('Content:', JSON.stringify(msg.content))
|
||||
})
|
||||
|
||||
console.log(`\nSearching profiles for ${nodeName}...`)
|
||||
console.debug(`\nSearching profiles for ${nodeName}...`)
|
||||
const users = renderSql(
|
||||
select('user_id, bio'),
|
||||
from('profiles'),
|
||||
@@ -64,9 +64,9 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
|
||||
const usersWithMentions = await pg.manyOrNone(users)
|
||||
console.log(`Found ${usersWithMentions.length} users:`)
|
||||
console.debug(`Found ${usersWithMentions.length} users:`)
|
||||
usersWithMentions.forEach((user) => {
|
||||
console.log('\nUser ID:', user.user_id)
|
||||
console.log('Bio:', JSON.stringify(user.bio))
|
||||
console.debug('\nUser ID:', user.user_id)
|
||||
console.debug('Bio:', JSON.stringify(user.bio))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ async function getTableInfo(pg: SupabaseDirectClient, tableName: string) {
|
||||
}
|
||||
|
||||
async function getFunctions(pg: SupabaseDirectClient) {
|
||||
console.log('Getting functions')
|
||||
console.debug('Getting functions')
|
||||
const rows = await pg.manyOrNone<{
|
||||
function_name: string
|
||||
definition: string
|
||||
@@ -196,7 +196,7 @@ async function getFunctions(pg: SupabaseDirectClient) {
|
||||
}
|
||||
|
||||
async function getViews(pg: SupabaseDirectClient) {
|
||||
console.log('Getting views')
|
||||
console.debug('Getting views')
|
||||
return pg.manyOrNone<{ view_name: string; definition: string }>(
|
||||
`SELECT
|
||||
table_name AS view_name,
|
||||
@@ -214,7 +214,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
(row) => row.tablename as string
|
||||
)
|
||||
|
||||
console.log(`Getting info for ${tables.length} tables`)
|
||||
console.debug(`Getting info for ${tables.length} tables`)
|
||||
const tableInfos = await Promise.all(
|
||||
tables.map((table) => getTableInfo(pg, table))
|
||||
)
|
||||
@@ -331,7 +331,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
await fs.writeFile(`${outputDir}/${tableInfo.tableName}.sql`, content)
|
||||
}
|
||||
|
||||
console.log('Writing remaining functions to functions.sql')
|
||||
console.debug('Writing remaining functions to functions.sql')
|
||||
let functionsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||
|
||||
for (const func of functions) {
|
||||
@@ -340,7 +340,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
|
||||
await fs.writeFile(`${outputDir}/functions.sql`, functionsContent)
|
||||
|
||||
console.log('Writing views to views.sql')
|
||||
console.debug('Writing views to views.sql')
|
||||
let viewsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||
|
||||
for (const view of views) {
|
||||
@@ -350,7 +350,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
|
||||
await fs.writeFile(`${outputDir}/views.sql`, viewsContent)
|
||||
|
||||
console.log('Prettifying SQL files...')
|
||||
console.debug('Prettifying SQL files...')
|
||||
execSync(
|
||||
`prettier --write ${outputDir}/*.sql --ignore-path ../supabase/.gitignore`
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ const removeNodesOfType = (
|
||||
runScript(async ({ pg }) => {
|
||||
const nodeType = 'linkPreview'
|
||||
|
||||
console.log('\nSearching comments for linkPreviews...')
|
||||
console.debug('\nSearching comments for linkPreviews...')
|
||||
const commentQuery = renderSql(
|
||||
select('id, content'),
|
||||
from('profile_comments'),
|
||||
@@ -38,21 +38,21 @@ runScript(async ({ pg }) => {
|
||||
)
|
||||
const comments = await pg.manyOrNone(commentQuery)
|
||||
|
||||
console.log(`Found ${comments.length} comments with linkPreviews`)
|
||||
console.debug(`Found ${comments.length} comments with linkPreviews`)
|
||||
|
||||
for (const comment of comments) {
|
||||
const newContent = removeNodesOfType(comment.content, nodeType)
|
||||
console.log('before', comment.content)
|
||||
console.log('after', newContent)
|
||||
console.debug('before', comment.content)
|
||||
console.debug('after', newContent)
|
||||
|
||||
await pg.none('update profile_comments set content = $1 where id = $2', [
|
||||
newContent,
|
||||
comment.id,
|
||||
])
|
||||
console.log('Updated comment:', comment.id)
|
||||
console.debug('Updated comment:', comment.id)
|
||||
}
|
||||
|
||||
console.log('\nSearching private messages for linkPreviews...')
|
||||
console.debug('\nSearching private messages for linkPreviews...')
|
||||
const messageQuery = renderSql(
|
||||
select('id, content'),
|
||||
from('private_user_messages'),
|
||||
@@ -60,17 +60,17 @@ runScript(async ({ pg }) => {
|
||||
)
|
||||
const messages = await pg.manyOrNone(messageQuery)
|
||||
|
||||
console.log(`Found ${messages.length} messages with linkPreviews`)
|
||||
console.debug(`Found ${messages.length} messages with linkPreviews`)
|
||||
|
||||
for (const msg of messages) {
|
||||
const newContent = removeNodesOfType(msg.content, nodeType)
|
||||
console.log('before', JSON.stringify(msg.content, null, 2))
|
||||
console.log('after', JSON.stringify(newContent, null, 2))
|
||||
console.debug('before', JSON.stringify(msg.content, null, 2))
|
||||
console.debug('after', JSON.stringify(newContent, null, 2))
|
||||
|
||||
await pg.none(
|
||||
'update private_user_messages set content = $1 where id = $2',
|
||||
[newContent, msg.id]
|
||||
)
|
||||
console.log('Updated message:', msg.id)
|
||||
console.debug('Updated message:', msg.id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import {ENV_CONFIG, getStorageBucketId} from "common/envs/constants";
|
||||
|
||||
export const getServiceAccountCredentials = () => {
|
||||
let keyPath = ENV_CONFIG.googleApplicationCredentials
|
||||
// console.log('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
|
||||
// console.debug('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
|
||||
if (!keyPath) {
|
||||
// throw new Error(
|
||||
// `Please set the GOOGLE_APPLICATION_CREDENTIALS environment variable to contain the path to your key file.`
|
||||
@@ -16,7 +16,7 @@ export const getServiceAccountCredentials = () => {
|
||||
if (!keyPath.startsWith('/')) {
|
||||
// Make relative paths relative to the current file
|
||||
keyPath = __dirname + '/' + keyPath
|
||||
// console.log(keyPath)
|
||||
// console.debug(keyPath)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -41,11 +41,11 @@ export async function deleteUserFiles(username: string) {
|
||||
const [files] = await bucket.getFiles({prefix: path});
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`No files found in bucket for user ${username}`);
|
||||
console.debug(`No files found in bucket for user ${username}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(files.map(file => file.delete()));
|
||||
console.log(`Deleted ${files.length} files for user ${username}`);
|
||||
console.debug(`Deleted ${files.length} files for user ${username}`);
|
||||
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const generateAvatarUrl = async (
|
||||
const buffer = await res.arrayBuffer()
|
||||
return await upload(userId, Buffer.from(buffer), bucket)
|
||||
} catch (e) {
|
||||
console.log('error generating avatar', e)
|
||||
console.debug('error generating avatar', e)
|
||||
return `https://${DOMAIN}/images/default-avatar.png`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export const constructPrefixTsQuery = (term: string) => {
|
||||
.replace(/'/g, "''")
|
||||
.replace(/[!&|():*<>]/g, '')
|
||||
.trim()
|
||||
console.log(`Term: "${sanitized}"`)
|
||||
console.debug(`Term: "${sanitized}"`)
|
||||
if (sanitized === '') return ''
|
||||
const tokens = sanitized.split(/\s+/)
|
||||
return tokens.join(' & ') + ':*'
|
||||
|
||||
@@ -9,12 +9,12 @@ export const initAdmin = () => {
|
||||
if (IS_LOCAL) {
|
||||
try {
|
||||
const serviceAccount = getServiceAccountCredentials()
|
||||
// console.log(serviceAccount)
|
||||
// console.debug(serviceAccount)
|
||||
if (!serviceAccount.project_id) {
|
||||
console.log(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
|
||||
console.debug(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
|
||||
return
|
||||
}
|
||||
console.log(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
|
||||
console.debug(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
|
||||
return admin.initializeApp({
|
||||
projectId: serviceAccount.project_id,
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
@@ -25,6 +25,6 @@ export const initAdmin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Initializing connection to default Firebase...`)
|
||||
console.debug(`Initializing connection to default Firebase...`)
|
||||
return admin.initializeApp()
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@ export function convertRow(row: ProfileAndUserRow): Profile
|
||||
export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
|
||||
if (!row) return null
|
||||
|
||||
return {
|
||||
// Remove internal/search-only fields from the returned profile row
|
||||
const profile: any = {
|
||||
...row,
|
||||
user: { ...row.user, name: row.name, username: row.username } as User,
|
||||
} as Profile
|
||||
}
|
||||
delete profile.bio_text
|
||||
delete profile.bio_tsv
|
||||
return profile as Profile
|
||||
}
|
||||
|
||||
const LOVER_COLS = 'profiles.*, name, username, users.data as user'
|
||||
|
||||
@@ -79,7 +79,7 @@ function writeLog(
|
||||
// record error properties in GCP if you just do log(err)
|
||||
output['error'] = msg
|
||||
}
|
||||
console.log(JSON.stringify(output, replacer))
|
||||
console.debug(JSON.stringify(output, replacer))
|
||||
} else {
|
||||
const category = Object.values(pick(data, DISPLAY_CATEGORY_KEYS)).join()
|
||||
const categoryLabel = category ? dim(category) + ' ' : ''
|
||||
|
||||
@@ -104,7 +104,7 @@ export class MetricWriter {
|
||||
for (const entry of freshEntries) {
|
||||
entry.fresh = false
|
||||
}
|
||||
if (!IS_GOOGLE_CLOUD) {
|
||||
if (IS_GOOGLE_CLOUD) {
|
||||
log.debug('Writing GCP metrics.', {entries: freshEntries})
|
||||
if (this.instance == null) {
|
||||
this.instance = await getInstanceInfo()
|
||||
|
||||
@@ -64,7 +64,7 @@ const newClient = (
|
||||
...settings,
|
||||
}
|
||||
|
||||
// console.log(config)
|
||||
// console.debug(config)
|
||||
|
||||
return pgp(config)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,15 @@ import {
|
||||
import {IS_LOCAL} from "common/envs/constants";
|
||||
import {getWebsocketUrl} from "common/api/utils";
|
||||
|
||||
// Extend the type definition locally
|
||||
interface HeartbeatWebSocket extends WebSocket {
|
||||
isAlive?: boolean
|
||||
}
|
||||
|
||||
const SWITCHBOARD = new Switchboard()
|
||||
|
||||
// if a connection doesn't ping for this long, we assume the other side is toast
|
||||
const CONNECTION_TIMEOUT_MS = 60 * 1000
|
||||
// const CONNECTION_TIMEOUT_MS = 60 * 1000
|
||||
|
||||
export class MessageParseError extends Error {
|
||||
details?: unknown
|
||||
@@ -52,7 +57,7 @@ function parseMessage(data: RawData): ClientMessage {
|
||||
}
|
||||
}
|
||||
|
||||
function processMessage(ws: WebSocket, data: RawData): ServerMessage<'ack'> {
|
||||
function processMessage(ws: HeartbeatWebSocket, data: RawData): ServerMessage<'ack'> {
|
||||
try {
|
||||
const msg = parseMessage(data)
|
||||
const { type, txid } = msg
|
||||
@@ -129,21 +134,26 @@ export function listen(server: HttpServer, path: string) {
|
||||
let deadConnectionCleaner: NodeJS.Timeout | undefined
|
||||
wss.on('listening', () => {
|
||||
log.info(`Web socket server listening on ${path}. ${getWebsocketUrl()}`)
|
||||
deadConnectionCleaner = setInterval(function ping() {
|
||||
const now = Date.now()
|
||||
for (const ws of wss.clients) {
|
||||
const lastSeen = SWITCHBOARD.getClient(ws).lastSeen
|
||||
if (lastSeen < now - CONNECTION_TIMEOUT_MS) {
|
||||
ws.terminate()
|
||||
deadConnectionCleaner = setInterval(() => {
|
||||
for (const ws of wss.clients as Set<HeartbeatWebSocket>) {
|
||||
if (ws.isAlive === false) {
|
||||
log.debug('Terminating dead connection');
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.isAlive = false;
|
||||
// log.debug('Sending ping to client');
|
||||
ws.ping();
|
||||
}
|
||||
}, CONNECTION_TIMEOUT_MS)
|
||||
}, 25000);
|
||||
})
|
||||
wss.on('error', (err) => {
|
||||
log.error('Error on websocket server.', { error: err })
|
||||
})
|
||||
wss.on('connection', (ws) => {
|
||||
// todo: should likely kill connections that haven't sent any ping for a long time
|
||||
wss.on('connection', (ws: HeartbeatWebSocket) => {
|
||||
ws.isAlive = true;
|
||||
// log.debug('Received pong from client');
|
||||
ws.on('pong', () => (ws.isAlive = true));
|
||||
metrics.inc('ws/connections_established')
|
||||
metrics.set('ws/open_connections', wss.clients.size)
|
||||
log.debug('WS client connected.')
|
||||
|
||||
@@ -9,6 +9,7 @@ END$$;
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
age INTEGER NULL,
|
||||
bio JSONB,
|
||||
bio_length integer null,
|
||||
born_in_location TEXT,
|
||||
city TEXT NOT NULL,
|
||||
city_latitude NUMERIC(9, 6),
|
||||
@@ -79,6 +80,10 @@ CREATE UNIQUE INDEX unique_user_id ON public.profiles USING btree (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
|
||||
ON public.profiles USING btree (last_modification_time);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
|
||||
ON profiles (bio_length);
|
||||
|
||||
|
||||
-- Functions and Triggers
|
||||
CREATE
|
||||
OR REPLACE FUNCTION update_last_modification_time()
|
||||
@@ -110,25 +115,31 @@ CREATE INDEX profiles_bio_trgm_idx
|
||||
|
||||
|
||||
--- bio_text
|
||||
-- ALTER TABLE profiles ADD COLUMN bio_text tsvector;
|
||||
--
|
||||
-- CREATE OR REPLACE FUNCTION profiles_bio_tsvector_update()
|
||||
-- RETURNS trigger AS $$
|
||||
-- BEGIN
|
||||
-- new.bio_text := to_tsvector(
|
||||
-- 'english',
|
||||
-- (
|
||||
-- SELECT string_agg(trim(both '"' from x::text), ' ')
|
||||
-- FROM jsonb_path_query(new.bio, '$.**.text'::jsonpath) AS x
|
||||
-- )
|
||||
-- );
|
||||
-- RETURN new;
|
||||
-- END;
|
||||
-- $$ LANGUAGE plpgsql;
|
||||
--
|
||||
-- CREATE TRIGGER profiles_bio_tsvector_trigger
|
||||
-- BEFORE INSERT OR UPDATE OF bio ON profiles
|
||||
-- FOR EACH ROW EXECUTE FUNCTION profiles_bio_tsvector_update();
|
||||
--
|
||||
-- create index on profiles using gin(bio_text);
|
||||
ALTER TABLE profiles ADD COLUMN bio_text TEXT;
|
||||
UPDATE profiles
|
||||
SET bio_text = (
|
||||
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
|
||||
FROM jsonb_path_query(bio, '$.**.text') AS t(value)
|
||||
);
|
||||
|
||||
ALTER TABLE profiles ADD COLUMN bio_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED;
|
||||
|
||||
CREATE INDEX profiles_bio_tsv_idx ON profiles USING GIN (bio_tsv);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_bio_text()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.bio_text := (
|
||||
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
|
||||
FROM jsonb_path_query(NEW.bio, '$.**.text') AS t(value)
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_update_bio_text
|
||||
BEFORE INSERT OR UPDATE OF bio ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_bio_text();
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PROJECT=compass-130ba
|
||||
|
||||
TIMESTAMP=$(date +"%F_%H-%M-%S")
|
||||
|
||||
DESTINATION=./data/$TIMESTAMP
|
||||
|
||||
mkdir -p $DESTINATION
|
||||
gsutil -m cp -r gs://$PROJECT.firebasestorage.app $DESTINATION
|
||||
|
||||
echo Backup of Firebase Storage done
|
||||
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
locals {
|
||||
project = "compass-130ba"
|
||||
region = "us-west1"
|
||||
zone = "us-west1-b"
|
||||
service_name = "backup"
|
||||
machine_type = "e2-micro"
|
||||
}
|
||||
|
||||
variable "env" {
|
||||
description = "Environment (env or prod)"
|
||||
type = string
|
||||
default = "prod"
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = local.project
|
||||
region = local.region
|
||||
zone = local.zone
|
||||
}
|
||||
|
||||
# Service account for the VM (needs Secret Manager + Storage access)
|
||||
resource "google_service_account" "backup_vm_sa" {
|
||||
account_id = "backup-vm-sa"
|
||||
display_name = "Backup VM Service Account"
|
||||
}
|
||||
|
||||
# IAM roles
|
||||
resource "google_project_iam_member" "backup_sa_secret_manager" {
|
||||
project = "compass-130ba"
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "backup_sa_storage_admin" {
|
||||
project = "compass-130ba"
|
||||
role = "roles/storage.objectAdmin"
|
||||
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
|
||||
}
|
||||
|
||||
# Minimal VM
|
||||
resource "google_compute_instance" "backup_vm" {
|
||||
name = "supabase-backup-vm"
|
||||
machine_type = local.machine_type
|
||||
zone = local.zone
|
||||
|
||||
boot_disk {
|
||||
initialize_params {
|
||||
image = "debian-11-bullseye-v20250915"
|
||||
size = 20
|
||||
}
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
access_config {}
|
||||
}
|
||||
|
||||
service_account {
|
||||
email = google_service_account.backup_vm_sa.email
|
||||
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
|
||||
}
|
||||
|
||||
metadata_startup_script = <<-EOT
|
||||
#!/bin/bash
|
||||
apt-get update
|
||||
apt-get install -y postgresql-client cron wget curl unzip
|
||||
|
||||
# Add PostgreSQL repo
|
||||
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-client-17
|
||||
sudo apt-get install -y mailutils
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p /home/martin/supabase_backups
|
||||
chown -R martin:martin /home/martin
|
||||
|
||||
# Example backup script
|
||||
cat <<'EOF' > /home/martin/backup.sh
|
||||
#!/bin/bash
|
||||
|
||||
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname "$0")
|
||||
|
||||
export ENV=prod
|
||||
|
||||
if [ "$ENV" = "prod" ]; then
|
||||
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
|
||||
elif [ "$ENV" = "dev" ]; then
|
||||
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
|
||||
else
|
||||
echo "Error: ENV must be 'prod' or 'dev'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Config
|
||||
PGPORT="5432"
|
||||
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
|
||||
PGDATABASE="postgres"
|
||||
|
||||
# Retrieve password from Secret Manager
|
||||
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
|
||||
|
||||
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
|
||||
BACKUP_DIR="/tmp/supabase_backups"
|
||||
RETENTION_DAYS=30
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
TIMESTAMP=$(date +"%F_%H-%M-%S")
|
||||
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
|
||||
|
||||
export PGPASSWORD
|
||||
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Backup failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Backup successful: $BACKUP_FILE"
|
||||
|
||||
# UPLOAD TO GCS
|
||||
echo "Uploading backup to GCS..."
|
||||
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
|
||||
|
||||
# LOCAL RETENTION
|
||||
LOCAL_RETENTION_DAYS=7
|
||||
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
|
||||
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
|
||||
|
||||
# GCS RETENTION
|
||||
echo "Cleaning old backups from GCS..."
|
||||
gsutil ls "$BUCKET_NAME/" | while read file; do
|
||||
filename=$(basename "$file")
|
||||
# Extract timestamp from filename
|
||||
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
|
||||
# Convert to seconds since epoch
|
||||
file_date="2025-09-24_13-00-54"
|
||||
date_part=${file_date%_*} # "2025-09-24"
|
||||
time_part=${file_date#*_} # "13-00-54"
|
||||
time_part=${time_part//-/:} # "13:00:54"
|
||||
file_ts=$(date -d "$date_part $time_part" +%s)
|
||||
# echo "$file, $filename, $file_date, $file_ts"
|
||||
if [ -z "$file_ts" ]; then
|
||||
continue
|
||||
fi
|
||||
now=$(date +%s)
|
||||
diff_days=$(( (now - file_ts) / 86400 ))
|
||||
echo "File: $filename is $diff_days days old."
|
||||
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
|
||||
echo "Deleting $file from GCS..."
|
||||
gsutil rm "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Backup and retention process completed at $(date)."
|
||||
EOF
|
||||
|
||||
chmod +x /home/martin/backup.sh
|
||||
|
||||
# Add cron job: daily at 2AM
|
||||
( crontab -l 2>/dev/null; echo '0 2 * * * /home/martin/backup.sh >> /home/martin/backup.log 2>&1 || curl -H "Content-Type: application/json" -X POST -d "{\"content\": \"❌ Backup FAILED on $(hostname) at $(date)\"}" https://discord.com/api/webhooks/1420405275340574873/XgF5pgHABvvWT2fyWASBs3VhAF7Zy11rCH2BkI_RBxH1Xd5duWxGtukrc1cPy1ZucNwx' ) | crontab -
|
||||
# tail -f /home/martin/backup.log
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname "$0")
|
||||
|
||||
#gcloud compute firewall-rules create allow-iap-ssh \
|
||||
# --direction=INGRESS \
|
||||
# --action=ALLOW \
|
||||
# --rules=tcp:22 \
|
||||
# --source-ranges=35.235.240.0/20 \
|
||||
# --target-tags=iap-ssh
|
||||
# gcloud compute instances add-tags "supabase-backup-vm" --tags=iap-ssh --zone="us-west1-b"
|
||||
|
||||
|
||||
gcloud compute ssh --zone "us-west1-b" "supabase-backup-vm" --project "compass-130ba" --tunnel-through-iap
|
||||
|
||||
# sudo crontab -u backup -l
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname "$0")
|
||||
|
||||
export ENV=prod
|
||||
|
||||
if [ "$ENV" = "prod" ]; then
|
||||
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
|
||||
elif [ "$ENV" = "dev" ]; then
|
||||
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
|
||||
else
|
||||
echo "Error: ENV must be 'prod' or 'dev'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Config
|
||||
PGPORT="5432"
|
||||
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
|
||||
PGDATABASE="postgres"
|
||||
|
||||
# Retrieve password from Secret Manager
|
||||
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
|
||||
|
||||
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
|
||||
BACKUP_DIR="/tmp/supabase_backups"
|
||||
RETENTION_DAYS=30
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
TIMESTAMP=$(date +"%F_%H-%M-%S")
|
||||
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
|
||||
|
||||
export PGPASSWORD
|
||||
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Backup failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Backup successful: $BACKUP_FILE"
|
||||
|
||||
# UPLOAD TO GCS
|
||||
echo "Uploading backup to GCS..."
|
||||
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
|
||||
|
||||
# LOCAL RETENTION
|
||||
LOCAL_RETENTION_DAYS=7
|
||||
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
|
||||
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
|
||||
|
||||
# GCS RETENTION
|
||||
echo "Cleaning old backups from GCS..."
|
||||
gsutil ls "$BUCKET_NAME/" | while read file; do
|
||||
filename=$(basename "$file")
|
||||
# Extract timestamp from filename
|
||||
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
|
||||
# Convert to seconds since epoch
|
||||
file_date="2025-09-24_13-00-54"
|
||||
date_part=${file_date%_*} # "2025-09-24"
|
||||
time_part=${file_date#*_} # "13-00-54"
|
||||
time_part=${time_part//-/:} # "13:00:54"
|
||||
file_ts=$(date -d "$date_part $time_part" +%s)
|
||||
# echo "$file, $filename, $file_date, $file_ts"
|
||||
if [ -z "$file_ts" ]; then
|
||||
continue
|
||||
fi
|
||||
now=$(date +%s)
|
||||
diff_days=$(( (now - file_ts) / 86400 ))
|
||||
echo "File: $filename is $diff_days days old."
|
||||
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
|
||||
echo "Deleting $file from GCS..."
|
||||
gsutil rm "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Backup and retention process completed at $(date)."
|
||||
@@ -321,6 +321,7 @@ export const API = (_apiTypeCheck = {
|
||||
wants_kids_strength: z.coerce.number().optional(),
|
||||
has_kids: z.coerce.number().optional(),
|
||||
is_smoker: z.coerce.boolean().optional(),
|
||||
shortBio: z.coerce.boolean().optional(),
|
||||
geodbCityIds: arraybeSchema.optional(),
|
||||
compatibleWithUserId: z.string().optional(),
|
||||
orderBy: z
|
||||
|
||||
@@ -20,10 +20,10 @@ export class APIError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = 'v0'
|
||||
const prefix = ''
|
||||
|
||||
export function pathWithPrefix(path: string) {
|
||||
return `/${prefix}${path}`
|
||||
return `${prefix}${path}`
|
||||
}
|
||||
|
||||
export function getWebsocketUrl() {
|
||||
@@ -33,5 +33,5 @@ export function getWebsocketUrl() {
|
||||
|
||||
export function getApiUrl(path: string) {
|
||||
const protocol = IS_LOCAL ? 'http' : 'https'
|
||||
return `${protocol}://${BACKEND_DOMAIN}/${prefix}/${path}`
|
||||
return `${protocol}://${BACKEND_DOMAIN}${prefix}/${path}`
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export class APIRealtimeClient {
|
||||
// subscribers by the topic they are subscribed to
|
||||
subscriptions: Map<string, BroadcastHandler[]>
|
||||
connectTimeout?: NodeJS.Timeout
|
||||
heartbeat?: NodeJS.Timeout
|
||||
heartbeat?: number | undefined;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
@@ -90,10 +90,12 @@ export class APIRealtimeClient {
|
||||
if (VERBOSE_LOGGING) {
|
||||
console.info('API websocket opened.')
|
||||
}
|
||||
this.heartbeat = setInterval(
|
||||
async () => this.sendMessage('ping', {}).catch(console.error),
|
||||
30000
|
||||
)
|
||||
// Send a heartbeat ping every 25s
|
||||
this.heartbeat = window.setInterval(() => {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage('ping', {}).catch(console.error);
|
||||
}
|
||||
}, 25000);
|
||||
if (this.subscriptions.size > 0) {
|
||||
this.sendMessage('subscribe', {
|
||||
topics: Array.from(this.subscriptions.keys()),
|
||||
@@ -105,7 +107,7 @@ export class APIRealtimeClient {
|
||||
if (VERBOSE_LOGGING) {
|
||||
console.info(`API websocket closed with code=${ev.code}: ${ev.reason}`)
|
||||
}
|
||||
clearInterval(this.heartbeat)
|
||||
if (this.heartbeat) clearInterval(this.heartbeat)
|
||||
|
||||
// mqp: we might need to change how the txn stuff works if we ever want to
|
||||
// implement "wait until i am subscribed, and then do something" in a component.
|
||||
|
||||
@@ -62,6 +62,7 @@ export const baseProfilesSchema = z.object({
|
||||
visibility: z.union([z.literal('public'), z.literal('member')]),
|
||||
|
||||
bio: contentSchema.optional().nullable(),
|
||||
bio_length: z.number().optional().nullable(),
|
||||
|
||||
geodb_city_id: z.string().optional(),
|
||||
city: z.string(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const MAX_INT = 99999
|
||||
export const MIN_INT = -MAX_INT
|
||||
|
||||
export const supportEmail = 'compass.meet.info@gmail.com';
|
||||
export const marketingEmail = 'compass.meet.marketing@gmail.com';
|
||||
export const supportEmail = 'hello@compassmeet.com';
|
||||
// export const marketingEmail = 'hello@compassmeet.com';
|
||||
|
||||
export const githubRepo = "https://github.com/CompassConnections/Compass";
|
||||
export const githubIssues = `${githubRepo}/issues`
|
||||
@@ -10,6 +10,13 @@ export const githubIssues = `${githubRepo}/issues`
|
||||
export const paypalLink = "https://www.paypal.com/paypalme/CompassConnections"
|
||||
export const patreonLink = "https://patreon.com/CompassMeet"
|
||||
export const discordLink = "https://discord.gg/8Vd7jzqjun"
|
||||
export const stoatLink = "https://stt.gg/YKQp81yA"
|
||||
export const redditLink = "https://www.reddit.com/r/CompassConnect"
|
||||
export const xLink = "https://x.com/compassmeet"
|
||||
export const formLink = "https://forms.gle/tKnXUMAbEreMK6FC6"
|
||||
|
||||
export const pStyle = "mt-1 text-gray-800 dark:text-white whitespace-pre-line";
|
||||
|
||||
export const IS_MAINTENANCE = false; // set to true to enable maintenance mode banner
|
||||
|
||||
export const MIN_BIO_LENGTH = 250;
|
||||
|
||||
@@ -26,7 +26,7 @@ export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
|
||||
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
|
||||
export const IS_LOCAL = !IS_GOOGLE_CLOUD && !IS_VERCEL
|
||||
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_LOCAL ? 'local' : 'unknown'
|
||||
console.log(`Running in ${HOSTING_ENV} (${ENV})`,);
|
||||
console.debug(`Running in ${HOSTING_ENV} (${ENV})`,);
|
||||
|
||||
// class MissingKeyError implements Error {
|
||||
// constructor(key: string) {
|
||||
@@ -68,20 +68,25 @@ export const VERIFIED_USERNAMES = [
|
||||
export const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
||||
|
||||
export const RESERVED_PATHS = [
|
||||
'404',
|
||||
'_app',
|
||||
'_document',
|
||||
'_next',
|
||||
'about',
|
||||
'ad',
|
||||
'add-funds',
|
||||
'ads',
|
||||
'admin',
|
||||
'ads',
|
||||
'analytics',
|
||||
'api',
|
||||
'browse',
|
||||
'career',
|
||||
'careers',
|
||||
'charts',
|
||||
'chat',
|
||||
'chats',
|
||||
'common',
|
||||
'confirm-email',
|
||||
'contact',
|
||||
'contacts',
|
||||
'create',
|
||||
@@ -89,6 +94,8 @@ export const RESERVED_PATHS = [
|
||||
'discord',
|
||||
'embed',
|
||||
'facebook',
|
||||
'faq',
|
||||
'financials',
|
||||
'find',
|
||||
'github',
|
||||
'google',
|
||||
@@ -96,18 +103,23 @@ export const RESERVED_PATHS = [
|
||||
'groups',
|
||||
'help',
|
||||
'home',
|
||||
'index',
|
||||
'link',
|
||||
'linkAccount',
|
||||
'links',
|
||||
'live',
|
||||
'login',
|
||||
'love-questions',
|
||||
'manifest',
|
||||
'market',
|
||||
'markets',
|
||||
'md',
|
||||
'members',
|
||||
'message',
|
||||
'messages',
|
||||
'notifications',
|
||||
'og-test',
|
||||
'organization',
|
||||
'payments',
|
||||
'privacy',
|
||||
'profile',
|
||||
@@ -115,16 +127,22 @@ export const RESERVED_PATHS = [
|
||||
'questions',
|
||||
'referral',
|
||||
'referrals',
|
||||
'register',
|
||||
'send',
|
||||
'server-sitemap',
|
||||
'sign-in',
|
||||
'sign-in-waiting',
|
||||
'signin',
|
||||
'signup',
|
||||
'sitemap',
|
||||
'slack',
|
||||
'social',
|
||||
'stats',
|
||||
'styles',
|
||||
'support',
|
||||
'team',
|
||||
'terms',
|
||||
'tips-bio',
|
||||
'twitch',
|
||||
'twitter',
|
||||
'user',
|
||||
|
||||
@@ -5,7 +5,7 @@ export const DEV_CONFIG: EnvConfig = {
|
||||
domain: 'dev.compassmeet.com',
|
||||
backendDomain: 'api.dev.compassmeet.com',
|
||||
supabaseInstanceId: 'zbspxezubpzxmuxciurg',
|
||||
supabasePwd: 'FO3y0G7chzdq6aE7', // For database write access (dev). A 16-character password with digits and letters.
|
||||
supabasePwd: 'ZTNlifGKofSKhu8c', // For database write access (dev). A 16-character password with digits and letters.
|
||||
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpic3B4ZXp1YnB6eG11eGNpdXJnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc2ODM0MTMsImV4cCI6MjA3MzI1OTQxM30.ZkM7zlawP8Nke0T3KJrqpOQ4DzqPaXTaJXLC2WU8Y7c',
|
||||
googleApplicationCredentials: 'googleApplicationCredentials-dev.json',
|
||||
firebaseConfig: {
|
||||
@@ -13,10 +13,13 @@ export const DEV_CONFIG: EnvConfig = {
|
||||
authDomain: "compass-57c3c.firebaseapp.com",
|
||||
projectId: "compass-57c3c",
|
||||
storageBucket: "compass-57c3c.firebasestorage.app",
|
||||
privateBucket: 'compass-private.firebasestorage.app',
|
||||
privateBucket: 'compass-130ba-private',
|
||||
messagingSenderId: "297460199314",
|
||||
appId: "1:297460199314:web:c45678c54285910e255b4b",
|
||||
measurementId: "G-N6LZ64EMJ2",
|
||||
region: 'us-west1',
|
||||
},
|
||||
adminIds: [
|
||||
'ULxLz04VW1V4vbnj5XLwvzCSkYd2', // Martin
|
||||
],
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const PROD_CONFIG: EnvConfig = {
|
||||
authDomain: "compass-130ba.firebaseapp.com",
|
||||
projectId: "compass-130ba",
|
||||
storageBucket: "compass-130ba.firebasestorage.app",
|
||||
privateBucket: 'compass-private.firebasestorage.app',
|
||||
privateBucket: 'compass-130ba-private',
|
||||
messagingSenderId: "253367029065",
|
||||
appId: "1:253367029065:web:b338785af99d4145095e98",
|
||||
measurementId: "G-2LSQYJQE6P",
|
||||
|
||||
@@ -7,6 +7,7 @@ export type FilterFields = {
|
||||
geodbCityIds: string[] | null
|
||||
genders: string[]
|
||||
name: string | undefined
|
||||
shortBio: boolean | undefined
|
||||
} & Pick<
|
||||
ProfileRow,
|
||||
| 'wants_kids_strength'
|
||||
@@ -47,6 +48,7 @@ export const initialFilters: Partial<FilterFields> = {
|
||||
is_smoker: undefined,
|
||||
pref_relation_styles: undefined,
|
||||
pref_gender: undefined,
|
||||
shortBio: undefined,
|
||||
orderBy: 'created_time',
|
||||
}
|
||||
export type OriginLocation = { id: string; name: string }
|
||||
|
||||
@@ -23,10 +23,10 @@ export const geodbFetch = async (endpoint: string) => {
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
console.log('geodbFetch', endpoint, data)
|
||||
console.debug('geodbFetch', endpoint, data)
|
||||
return {status: 'success', data}
|
||||
} catch (error) {
|
||||
console.log('geodbFetch', endpoint, error)
|
||||
console.debug('geodbFetch', endpoint, error)
|
||||
return {status: 'failure', data: error}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const isPreferredGender = (
|
||||
preferredGenders: string[] | undefined,
|
||||
gender: string | undefined
|
||||
) => {
|
||||
// console.log('isPreferredGender', preferredGenders, gender)
|
||||
// console.debug('isPreferredGender', preferredGenders, gender)
|
||||
if (preferredGenders === undefined || preferredGenders.length === 0 || gender === undefined) return true
|
||||
|
||||
// If simple gender preference, don't include non-binary.
|
||||
@@ -19,7 +19,7 @@ const isPreferredGender = (
|
||||
}
|
||||
|
||||
export const areGenderCompatible = (profile1: ProfileRow, profile2: ProfileRow) => {
|
||||
// console.log('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
|
||||
// console.debug('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
|
||||
return (
|
||||
isPreferredGender(profile1.pref_gender, profile2.gender) &&
|
||||
isPreferredGender(profile2.pref_gender, profile1.gender)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { User } from 'common/user'
|
||||
export type ProfileRow = Row<'profiles'>
|
||||
export type Profile = ProfileRow & { user: User }
|
||||
export const getProfileRow = async (userId: string, db: SupabaseClient) => {
|
||||
console.log('getProfileRow', userId)
|
||||
console.debug('getProfileRow', userId)
|
||||
const res = await run(db.from('profiles').select('*').eq('user_id', userId))
|
||||
return res.data[0]
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function formatFilters(filters: Partial<FilterFields>, location: location
|
||||
const typedKey = key as keyof FilterFields
|
||||
|
||||
if (value === undefined || value === null) return
|
||||
if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy') return
|
||||
if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy' || typedKey == 'shortBio') return
|
||||
if (Array.isArray(value) && value.length === 0) return
|
||||
if (initialFilters[typedKey] === value) return
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ type SecretId = (typeof secrets)[number]
|
||||
export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => {
|
||||
if (!ids.length && IS_LOCAL) return {}
|
||||
|
||||
// console.log('Fetching secrets...')
|
||||
// console.debug('Fetching secrets...')
|
||||
let client: SecretManagerServiceClient
|
||||
if (credentials) {
|
||||
const projectId = credentials['project_id']
|
||||
@@ -47,7 +47,7 @@ export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => {
|
||||
|
||||
const secretIds = ids.length > 0 ? ids : secrets
|
||||
|
||||
console.log('secretIds', secretIds)
|
||||
console.debug('secretIds', secretIds)
|
||||
|
||||
const fullSecretNames = secretIds.map(
|
||||
(secret: string) =>
|
||||
@@ -75,7 +75,7 @@ export const loadSecretsToEnv = async (credentials?: any) => {
|
||||
for (const [key, value] of Object.entries(allSecrets)) {
|
||||
if (key && value) {
|
||||
process.env[key] = value
|
||||
// console.log(key, value)
|
||||
// console.debug(key, value)
|
||||
}
|
||||
}
|
||||
refreshConfig()
|
||||
|
||||
@@ -221,45 +221,6 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profile_comments: {
|
||||
Row: {
|
||||
content: Json
|
||||
created_time: string
|
||||
hidden: boolean
|
||||
id: number
|
||||
on_user_id: string
|
||||
reply_to_comment_id: number | null
|
||||
user_avatar_url: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_username: string
|
||||
}
|
||||
Insert: {
|
||||
content: Json
|
||||
created_time?: string
|
||||
hidden?: boolean
|
||||
id?: never
|
||||
on_user_id: string
|
||||
reply_to_comment_id?: number | null
|
||||
user_avatar_url: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_username: string
|
||||
}
|
||||
Update: {
|
||||
content?: Json
|
||||
created_time?: string
|
||||
hidden?: boolean
|
||||
id?: never
|
||||
on_user_id?: string
|
||||
reply_to_comment_id?: number | null
|
||||
user_avatar_url?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
user_username?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
private_user_message_channel_members: {
|
||||
Row: {
|
||||
channel_id: number
|
||||
@@ -401,10 +362,52 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profile_comments: {
|
||||
Row: {
|
||||
content: Json
|
||||
created_time: string
|
||||
hidden: boolean
|
||||
id: number
|
||||
on_user_id: string
|
||||
reply_to_comment_id: number | null
|
||||
user_avatar_url: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_username: string
|
||||
}
|
||||
Insert: {
|
||||
content: Json
|
||||
created_time?: string
|
||||
hidden?: boolean
|
||||
id?: number
|
||||
on_user_id: string
|
||||
reply_to_comment_id?: number | null
|
||||
user_avatar_url: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_username: string
|
||||
}
|
||||
Update: {
|
||||
content?: Json
|
||||
created_time?: string
|
||||
hidden?: boolean
|
||||
id?: number
|
||||
on_user_id?: string
|
||||
reply_to_comment_id?: number | null
|
||||
user_avatar_url?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
user_username?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
age: number | null
|
||||
bio: Json | null
|
||||
bio_length: number | null
|
||||
bio_text: string | null
|
||||
bio_tsv: unknown | null
|
||||
born_in_location: string | null
|
||||
city: string
|
||||
city_latitude: number | null
|
||||
@@ -443,13 +446,16 @@ export type Database = {
|
||||
twitter: string | null
|
||||
university: string | null
|
||||
user_id: string
|
||||
visibility: Database['public']['Enums']['profile_visibility']
|
||||
visibility: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength: number
|
||||
website: string | null
|
||||
}
|
||||
Insert: {
|
||||
age?: number | null
|
||||
bio?: Json | null
|
||||
bio_length?: number | null
|
||||
bio_text?: string | null
|
||||
bio_tsv?: unknown | null
|
||||
born_in_location?: string | null
|
||||
city: string
|
||||
city_latitude?: number | null
|
||||
@@ -465,7 +471,7 @@ export type Database = {
|
||||
geodb_city_id?: string | null
|
||||
has_kids?: number | null
|
||||
height_in_inches?: number | null
|
||||
id?: never
|
||||
id?: number
|
||||
is_smoker?: boolean | null
|
||||
is_vegetarian_or_vegan?: boolean | null
|
||||
last_modification_time?: string
|
||||
@@ -488,13 +494,16 @@ export type Database = {
|
||||
twitter?: string | null
|
||||
university?: string | null
|
||||
user_id: string
|
||||
visibility?: Database['public']['Enums']['profile_visibility']
|
||||
visibility?: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength?: number
|
||||
website?: string | null
|
||||
}
|
||||
Update: {
|
||||
age?: number | null
|
||||
bio?: Json | null
|
||||
bio_length?: number | null
|
||||
bio_text?: string | null
|
||||
bio_tsv?: unknown | null
|
||||
born_in_location?: string | null
|
||||
city?: string
|
||||
city_latitude?: number | null
|
||||
@@ -510,7 +519,7 @@ export type Database = {
|
||||
geodb_city_id?: string | null
|
||||
has_kids?: number | null
|
||||
height_in_inches?: number | null
|
||||
id?: never
|
||||
id?: number
|
||||
is_smoker?: boolean | null
|
||||
is_vegetarian_or_vegan?: boolean | null
|
||||
last_modification_time?: string
|
||||
@@ -533,7 +542,7 @@ export type Database = {
|
||||
twitter?: string | null
|
||||
university?: string | null
|
||||
user_id?: string
|
||||
visibility?: Database['public']['Enums']['profile_visibility']
|
||||
visibility?: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength?: number
|
||||
website?: string | null
|
||||
}
|
||||
@@ -693,6 +702,10 @@ export type Database = {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: Record<string, unknown>[]
|
||||
}
|
||||
get_love_question_answers_and_lovers: {
|
||||
Args: { p_question_id: number }
|
||||
Returns: Record<string, unknown>[]
|
||||
}
|
||||
get_love_question_answers_and_profiles: {
|
||||
Args: { p_question_id: number }
|
||||
Returns: Record<string, unknown>[]
|
||||
@@ -755,7 +768,7 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
Enums: {
|
||||
profile_visibility: 'public' | 'member'
|
||||
lover_visibility: 'public' | 'member'
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
@@ -883,7 +896,7 @@ export type CompositeTypes<
|
||||
export const Constants = {
|
||||
public: {
|
||||
Enums: {
|
||||
profile_visibility: ['public', 'member'],
|
||||
lover_visibility: ['public', 'member'],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createClient(
|
||||
opts?: SupabaseClientOptionsGeneric<'public'>
|
||||
) {
|
||||
const url = `https://${instanceId}.supabase.co`
|
||||
// console.log('createClient', instanceId, key, opts)
|
||||
// console.debug('createClient', instanceId, key, opts)
|
||||
return createClientGeneric(
|
||||
url,
|
||||
key,
|
||||
|
||||
@@ -57,6 +57,7 @@ export const getDefaultNotificationPreferences = (isDev?: boolean) => {
|
||||
return defaults
|
||||
}
|
||||
|
||||
export const UNSUBSCRIBE_URL = 'https://compassmeet.com/notifications';
|
||||
export const getNotificationDestinationsForUser = (
|
||||
privateUser: PrivateUser,
|
||||
type: notification_preference
|
||||
@@ -72,7 +73,7 @@ export const getNotificationDestinationsForUser = (
|
||||
destinations.includes('browser') && !opt_out.includes('browser'),
|
||||
sendToMobile:
|
||||
destinations.includes('mobile') && !opt_out.includes('mobile'),
|
||||
unsubscribeUrl: 'https://compassmeet.com/notifications',
|
||||
unsubscribeUrl: UNSUBSCRIBE_URL,
|
||||
urlToManageThisNotification: '/notifications',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function baseApiCall(props: {
|
||||
body:
|
||||
params == null || method === 'GET' ? undefined : JSON.stringify(params),
|
||||
})
|
||||
// console.log(req)
|
||||
// console.debug(req)
|
||||
return fetch(req).then(async (resp) => {
|
||||
const json = (await resp.json()) as { [k: string]: any }
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -32,7 +32,7 @@ export function factorizeMatrix(
|
||||
const mFeatures = fillMatrix(m, FEATURES, initCell)
|
||||
const nFeatures = fillMatrix(n, FEATURES, initCell)
|
||||
|
||||
console.log('rows', m, 'columns', n, 'numPoints', points)
|
||||
console.debug('rows', m, 'columns', n, 'numPoints', points)
|
||||
|
||||
const updateFeature = (a: number, b: number, error: number) =>
|
||||
a + LEARNING_RATE * (2 * error * b - REGULARIZATION_RATE * a)
|
||||
@@ -75,7 +75,7 @@ export function factorizeMatrix(
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(iter, 'error', totalError / points)
|
||||
console.debug(iter, 'error', totalError / points)
|
||||
|
||||
// Complete factorization process if total error falls below a certain threshold
|
||||
if (totalError / points < THRESHOLD) break
|
||||
|
||||
@@ -44,7 +44,7 @@ export function wantsKidsToHasKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
|
||||
export function wantsKidsDatabaseToWantsKidsFilter(
|
||||
wantsKidsStrength: wantsKidsDatabase
|
||||
) {
|
||||
// console.log(wantsKidsStrength)
|
||||
// console.debug(wantsKidsStrength)
|
||||
if (wantsKidsStrength == wantsKidsLabels.no_preference.strength) {
|
||||
return wantsKidsLabels.no_preference.strength
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
{
|
||||
"bucket": "compass-130ba-private.firebasestorage.app",
|
||||
"bucket": "compass-130ba-private",
|
||||
"rules": "private-storage.rules"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "compass",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"common",
|
||||
|
||||
@@ -5,7 +5,7 @@ cd "$(dirname "$0")"/..
|
||||
|
||||
source .env
|
||||
|
||||
export url=http://localhost:8088/v0
|
||||
export url=http://localhost:8088
|
||||
#export url=https://api.compassmeet.com
|
||||
|
||||
export endpoint=/internal/send-search-notifications
|
||||
|
||||
@@ -9,6 +9,9 @@ if [ ! -f .env ]; then
|
||||
echo ".env file created from .env.example"
|
||||
fi
|
||||
|
||||
source .env.example
|
||||
source .env
|
||||
|
||||
echo $GOOGLE_CREDENTIALS_ENC_PWD
|
||||
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 -in secrets/googleApplicationCredentials-dev.json.enc -out backend/shared/src/googleApplicationCredentials-dev.json -pass pass:$GOOGLE_CREDENTIALS_ENC_PWD
|
||||
@@ -58,7 +58,7 @@ function AddCompatibilityQuestionModal(props: {
|
||||
)
|
||||
const afterAddQuestion = (newQuestion: rowFor<'love_questions'>) => {
|
||||
setDbQuestion(newQuestion)
|
||||
console.log('setDbQuestion', newQuestion)
|
||||
console.debug('setDbQuestion', newQuestion)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -137,7 +137,7 @@ function CreateCompatibilityModalContent(props: {
|
||||
options: generateJson(),
|
||||
};
|
||||
const newQuestion = await api('create-compatibility-question', data)
|
||||
console.log('create-compatibility-question', newQuestion, data)
|
||||
console.debug('create-compatibility-question', newQuestion, data)
|
||||
const q = newQuestion?.question
|
||||
if (q) {
|
||||
afterAddQuestion(q as rowFor<'love_questions'>)
|
||||
|
||||
@@ -157,6 +157,11 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
return (
|
||||
<Col className="h-full w-full gap-4">
|
||||
<Col className="gap-1">
|
||||
<Row className="text-blue-400 -mt-4 w-full justify-start text-sm">
|
||||
<span>
|
||||
Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.
|
||||
</span>
|
||||
</Row>
|
||||
{index !== null &&
|
||||
index !== undefined &&
|
||||
total !== null &&
|
||||
@@ -184,7 +189,7 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
<Col
|
||||
className={clsx(
|
||||
SCROLLABLE_MODAL_CLASS,
|
||||
'h-[30rem] w-full gap-4 sm:h-[30rem]'
|
||||
'h-[20rem] w-full gap-4 sm:h-[30rem]'
|
||||
)}
|
||||
>
|
||||
<Col className="gap-2">
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { MAX_DESCRIPTION_LENGTH } from 'common/envs/constants'
|
||||
import { Profile } from 'common/love/profile'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { Button } from 'web/components/buttons/button'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { TextEditor, useTextEditor } from 'web/components/widgets/editor'
|
||||
import { updateProfile } from 'web/lib/api'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import React, {useState} from "react";
|
||||
import {Editor} from '@tiptap/core'
|
||||
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
|
||||
import {updateProfile} from 'web/lib/api'
|
||||
import {track} from 'web/lib/service/analytics'
|
||||
import React, {useEffect, useState} from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import Link from "next/link"
|
||||
import {MIN_BIO_LENGTH} from "common/constants";
|
||||
|
||||
const placeHolder = "Tell us about yourself — and what you're looking for!";
|
||||
|
||||
@@ -20,13 +21,10 @@ Write a clear and engaging bio to help others understand who you are and the con
|
||||
- Connection goals (friendship, romantic, collaborative) and availability
|
||||
- What makes you unique and what you care about
|
||||
- Expectations, boundaries, and personality traits
|
||||
- How to contact you or start a conversation (email, social media, etc.)
|
||||
- Optional: romantic preferences, lifestyle habits, and conversation starters
|
||||
`
|
||||
|
||||
export function CharLimitText() {
|
||||
return <p>Profiles with fewer than 250 characters will be hidden by default from the profile grid — write a meaningful bio so others can find you through keyword search and connect intentionally.</p>
|
||||
}
|
||||
|
||||
export function BioTips() {
|
||||
const [showMoreInfo, setShowMoreInfo] = useState(false)
|
||||
|
||||
@@ -62,18 +60,16 @@ export function EditableBio(props: {
|
||||
onSave: () => void
|
||||
onCancel?: () => void
|
||||
}) {
|
||||
const { profile, onCancel, onSave } = props
|
||||
const editor = useTextEditor({
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: (profile.bio as JSONContent) ?? '',
|
||||
placeholder: placeHolder,
|
||||
})
|
||||
const {profile, onCancel, onSave} = props
|
||||
const [editor, setEditor] = useState<any>(null)
|
||||
const [textLength, setTextLength] = useState(0);
|
||||
|
||||
const hideButtons = editor?.getText().length === 0 && !profile.bio
|
||||
const hideButtons = (textLength === 0) && !profile.bio
|
||||
|
||||
const saveBio = async () => {
|
||||
if (!editor) return
|
||||
const { error } = await tryCatch(updateProfile({ bio: editor.getJSON() }))
|
||||
console.log(editor.getText().length)
|
||||
const {error} = await tryCatch(updateProfile({bio: editor.getJSON(), bio_length: editor.getText().length}))
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
@@ -85,10 +81,15 @@ export function EditableBio(props: {
|
||||
|
||||
return (
|
||||
<Col className="relative w-full">
|
||||
<CharLimitText/>
|
||||
<BioTips/>
|
||||
<TextEditor editor={editor} />
|
||||
|
||||
<BaseBio
|
||||
defaultValue={profile.bio}
|
||||
onEditor={(e) => {
|
||||
setEditor(e);
|
||||
e?.on('update', () => {
|
||||
setTextLength(e.getText().length);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!hideButtons && (
|
||||
<Row className="absolute bottom-1 right-1 justify-between gap-2">
|
||||
{onCancel && (
|
||||
@@ -112,40 +113,53 @@ export function EditableBio(props: {
|
||||
}
|
||||
|
||||
export function SignupBio(props: {
|
||||
onChange: (e: JSONContent) => void
|
||||
onChange: (e: Editor) => void
|
||||
}) {
|
||||
const { onChange } = props
|
||||
const editor = useTextEditor({
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: '',
|
||||
placeholder: placeHolder,
|
||||
})
|
||||
|
||||
// const [charLength, setCharLength] = useState(0)
|
||||
|
||||
const {onChange} = props
|
||||
return (
|
||||
<Col className="relative w-full">
|
||||
<CharLimitText/>
|
||||
<BioTips/>
|
||||
<TextEditor
|
||||
editor={editor}
|
||||
onBlur={() => {
|
||||
// console.log('onchange', editor?.getText())
|
||||
<BaseBio
|
||||
onBlur={(editor) => {
|
||||
if (!editor) return
|
||||
const e = editor.getJSON()
|
||||
// console.log(e)
|
||||
// const text = e.content.map((block: any) => block.content?.map((c: any) => c.text).join('') ?? '').join('');
|
||||
// setCharLength(text.length)
|
||||
// console.log(text, text.length)
|
||||
// if (text.length < 250) {
|
||||
// return; // do not save
|
||||
// }
|
||||
|
||||
// console.log('bio changed', e, profile.bio);
|
||||
onChange(e)
|
||||
onChange(editor)
|
||||
}}
|
||||
/>
|
||||
{/*<p>{charLength} / 250</p>*/}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
interface BaseBioProps {
|
||||
defaultValue?: any
|
||||
onBlur?: (editor: any) => void
|
||||
onEditor?: (editor: any) => void
|
||||
}
|
||||
|
||||
export function BaseBio({defaultValue, onBlur, onEditor}: BaseBioProps) {
|
||||
const editor = useTextEditor({
|
||||
// extensions: [StarterKit],
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: defaultValue,
|
||||
placeholder: placeHolder,
|
||||
})
|
||||
const textLength = editor?.getText().length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
onEditor?.(editor)
|
||||
}, [editor, onEditor])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{textLength < MIN_BIO_LENGTH &&
|
||||
<p>
|
||||
Add {MIN_BIO_LENGTH - textLength} more {MIN_BIO_LENGTH - textLength === 1 ? 'character' : 'characters'} so
|
||||
your profile can appear in search results—or leave it for now and explore others’ profiles first.
|
||||
</p>
|
||||
}
|
||||
<BioTips/>
|
||||
<TextEditor
|
||||
editor={editor}
|
||||
onBlur={() => onBlur?.(editor)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
// // })
|
||||
// setDialogOpen(false)
|
||||
//
|
||||
// // console.log('result', result)
|
||||
// // console.debug('result', result)
|
||||
//
|
||||
// // if (result.success) {
|
||||
// // window.location.reload()
|
||||
|
||||
18
web/components/buttons/general-button.tsx
Normal file
18
web/components/buttons/general-button.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export const GeneralButton = (props: {
|
||||
url: string
|
||||
content: string
|
||||
}) => {
|
||||
const {url, content} = props
|
||||
return <div className="rounded-xl shadow p-6 flex flex-col items-center">
|
||||
<Link
|
||||
href={url}
|
||||
className="px-6 py-2 rounded-full bg-gray-200 text-gray-800 font-semibold text-lg shadow hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 transition"
|
||||
target={url.startsWith('http') ? '_blank' : undefined}
|
||||
rel={url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
</div>;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { SimpleCopyTextButton } from 'web/components/buttons/copy-link-button'
|
||||
import { api } from 'web/lib/api'
|
||||
import { buildArray } from 'common/util/array'
|
||||
import { DeleteYourselfButton } from '../profile/delete-yourself'
|
||||
import {toast} from "react-hot-toast";
|
||||
|
||||
export function MoreOptionsUserButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
@@ -55,11 +56,22 @@ export function MoreOptionsUserButton(props: { user: User }) {
|
||||
<Button
|
||||
color={'red'}
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
api('ban-user', {
|
||||
userId,
|
||||
unban: user.isBannedFromPosting ?? false,
|
||||
})
|
||||
onClick={async () => {
|
||||
await toast.promise(
|
||||
api('ban-user', {
|
||||
userId,
|
||||
unban: user.isBannedFromPosting ?? false,
|
||||
}),
|
||||
{
|
||||
loading: 'Banning...',
|
||||
success: () => {
|
||||
return 'User banned!'
|
||||
},
|
||||
error: () => {
|
||||
return 'Error banning user'
|
||||
},
|
||||
}
|
||||
)
|
||||
}}
|
||||
>
|
||||
{user.isBannedFromPosting ? 'Banned' : 'Ban User'}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const ChatMessageItem = memo(function ChatMessageItem(props: {
|
||||
userId={id}
|
||||
/>
|
||||
)}
|
||||
<Col className="@sm:max-w-[calc(100vw-6rem)] @md:max-w-[80%] max-w-[calc(100vw-2rem)]">
|
||||
<Col className="sm:max-w-[calc(100vw-6rem)] md:max-w-[70%]">
|
||||
{firstOfUser && !isMe && chat.visibility !== 'system_status' && (
|
||||
<Row className={'items-center gap-3'}>
|
||||
<Link
|
||||
|
||||
@@ -13,6 +13,7 @@ import {RelationshipFilter, RelationshipFilterText,} from './relationship-filter
|
||||
import {MyMatchesToggle} from './my-matches-toggle'
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {FilterFields} from "common/filters";
|
||||
import {ShortBioToggle} from "web/components/filters/short-bio-toggle";
|
||||
|
||||
export function DesktopFilters(props: {
|
||||
filters: Partial<FilterFields>
|
||||
@@ -133,6 +134,12 @@ export function DesktopFilters(props: {
|
||||
}
|
||||
popoverClassName="bg-canvas-50"
|
||||
/>
|
||||
{/* Short Bios */}
|
||||
<ShortBioToggle
|
||||
updateFilter={updateFilter}
|
||||
filters={filters}
|
||||
hidden={false}
|
||||
/>
|
||||
{/* PREFERRED GENDER */}
|
||||
{/*<CustomizeableDropdown*/}
|
||||
{/* buttonContent={(open: boolean) => (*/}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {Profile} from 'common/love/profile'
|
||||
import {Gender} from 'common/gender'
|
||||
import {RelationshipType} from 'web/lib/util/convert-relationship-type'
|
||||
import {FilterFields} from "common/filters";
|
||||
import {ShortBioToggle} from "web/components/filters/short-bio-toggle";
|
||||
|
||||
export function MobileFilters(props: {
|
||||
filters: Partial<FilterFields>
|
||||
@@ -132,6 +133,15 @@ export function MobileFilters(props: {
|
||||
>
|
||||
<GenderFilter filters={filters} updateFilter={updateFilter}/>
|
||||
</MobileFilterSection>
|
||||
{/* Short Bios */}
|
||||
|
||||
<Col className="p-4 pb-2">
|
||||
<ShortBioToggle
|
||||
updateFilter={updateFilter}
|
||||
filters={filters}
|
||||
hidden={false}
|
||||
/>
|
||||
</Col>
|
||||
{/* PREFERRED GENDER */}
|
||||
{/*<MobileFilterSection*/}
|
||||
{/* title="Interested in"*/}
|
||||
|
||||
@@ -25,7 +25,7 @@ function isOrderBy(input: string): input is FilterFields['orderBy'] {
|
||||
}
|
||||
|
||||
const TYPING_SPEED = 100; // ms per character
|
||||
const HOLD_TIME = 5000; // ms to hold full word before deleting or switching
|
||||
const HOLD_TIME = 2000; // ms to hold full word before deleting or switching
|
||||
export const WORDS: string[] = [
|
||||
// Values
|
||||
"Minimalism",
|
||||
|
||||
33
web/components/filters/short-bio-toggle.tsx
Normal file
33
web/components/filters/short-bio-toggle.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import clsx from 'clsx'
|
||||
import {FilterFields} from "common/filters";
|
||||
|
||||
export function ShortBioToggle(props: {
|
||||
filters: Partial<FilterFields>
|
||||
updateFilter: (newState: Partial<FilterFields>) => void
|
||||
hidden: boolean
|
||||
}) {
|
||||
const {filters, updateFilter, hidden} = props
|
||||
if (hidden) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const label = 'Include Short Bios'
|
||||
|
||||
const on = filters.shortBio || false
|
||||
|
||||
return (
|
||||
<Row className={clsx('mr-2 items-center', on && 'font-semibold')}>
|
||||
<input
|
||||
id={label}
|
||||
type="checkbox"
|
||||
className="border-ink-300 bg-canvas-0 dark:border-ink-500 text-primary-600 focus:ring-primary-500 h-4 w-4 rounded"
|
||||
checked={on}
|
||||
onChange={(e) => updateFilter({shortBio: e.target.checked ? true : undefined})}
|
||||
/>
|
||||
<label htmlFor={label} className={clsx('text-ink-600 ml-2')}>
|
||||
{label}
|
||||
</label>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export const useFilters = (you: Profile | undefined) => {
|
||||
(you?.wants_kids_strength ?? 2) as wantsKidsDatabase
|
||||
),
|
||||
}
|
||||
console.log(you, yourFilters)
|
||||
console.debug(you, yourFilters)
|
||||
|
||||
const isYourFilters =
|
||||
!!you &&
|
||||
|
||||
@@ -72,9 +72,11 @@ export function LoggedOutHome() {
|
||||
<AboutBox title="Built for Depth" text="Search and filter by values, interests, goals, and keywords — from “stoicism” to “sustainable living.” Surface the connections that truly matter."/>
|
||||
<AboutBox title="Community Owned & Open Source" text="Free forever. No ads, no subscriptions. Built by the people who use it, for the benefit of everyone."/>
|
||||
</div>
|
||||
<p>
|
||||
Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to browsing — a public digital good designed to serve people, not profit.
|
||||
</p>
|
||||
<div className="mt-10 max-w-xl mx-auto">
|
||||
<p className="text-center">
|
||||
Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to browsing — a public digital good designed to serve people, not profit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="block lg:hidden h-12"></div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {HomeIcon, QuestionMarkCircleIcon} from '@heroicons/react/outline'
|
||||
import {
|
||||
GlobeAltIcon,
|
||||
HomeIcon as SolidHomeIcon,
|
||||
LinkIcon,
|
||||
QuestionMarkCircleIcon as SolidQuestionIcon,
|
||||
UserCircleIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
@@ -21,6 +23,7 @@ import Sidebar from './nav/love-sidebar'
|
||||
import {useProfile} from 'web/hooks/use-profile'
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {NotificationsIcon, SolidNotificationsIcon} from './notifications-icon'
|
||||
import {IS_MAINTENANCE} from "common/constants";
|
||||
|
||||
export function LovePage(props: {
|
||||
trackPageView: string | false
|
||||
@@ -43,13 +46,11 @@ export function LovePage(props: {
|
||||
const profile = useProfile()
|
||||
const bottomNavOptions = user
|
||||
? getBottomNavigation(user, profile)
|
||||
: signedOutNavigation()
|
||||
: getBottomSignedOutNavigation()
|
||||
// const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const desktopSidebarOptions = getDesktopNav(user)
|
||||
const desktopSidebarOptions = getDesktopNavigation(user)
|
||||
|
||||
const mobileSidebarOptions = user
|
||||
? getSidebarNavigation(() => setIsAddFundsModalOpen(true))
|
||||
: []
|
||||
const mobileSidebarOptions = getMobileSidebar(() => setIsAddFundsModalOpen(true))
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
trackPageView && useTracking(`view love ${trackPageView}`, trackPageProps)
|
||||
@@ -58,7 +59,7 @@ export function LovePage(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleOneTapLogin className="fixed bottom-12 right-4 z-[1000]" />
|
||||
<GoogleOneTapLogin className="fixed bottom-12 right-4 z-[1000]"/>
|
||||
<Col
|
||||
className={clsx(
|
||||
'pb-[58px] lg:pb-0', // bottom bar padding
|
||||
@@ -69,13 +70,18 @@ export function LovePage(props: {
|
||||
position={isMobile ? 'bottom-center' : 'top-center'}
|
||||
containerClassName="!bottom-[70px]"
|
||||
/>
|
||||
{/* Maintenance banner */}
|
||||
{IS_MAINTENANCE &&
|
||||
<div className="lg:col-span-12 w-full bg-orange-500 text-white text-center text-sm py-2 px-3">
|
||||
Maintenance in progress: Some features may be broken for the next few hours.
|
||||
</div>}
|
||||
{hideSidebar ? (
|
||||
<div className="lg:col-span-2 lg:flex" />
|
||||
<div className="lg:col-span-2 lg:flex"/>
|
||||
) : (
|
||||
<Sidebar
|
||||
navigationOptions={desktopSidebarOptions}
|
||||
className="sticky top-0 hidden self-start px-2 lg:col-span-2 lg:flex sidebar-nav bg-canvas-25"
|
||||
/>
|
||||
/>
|
||||
)}
|
||||
<main
|
||||
className={clsx(
|
||||
@@ -97,14 +103,23 @@ export function LovePage(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const Profiles = { name: 'Profiles', href: '/', icon: SolidHomeIcon };
|
||||
const ProfilesHome = { name: 'Profiles', href: '/', icon: HomeIcon };
|
||||
const faq = { name: 'FAQ', href: '/faq', icon: SolidQuestionIcon };
|
||||
const About = { name: 'About', href: '/about', icon: QuestionMarkCircleIcon };
|
||||
const Signin = { name: 'Sign in', href: '/signin', icon: UserCircleIcon };
|
||||
const Profiles = {name: 'Profiles', href: '/', icon: SolidHomeIcon};
|
||||
const ProfilesHome = {name: 'Profiles', href: '/', icon: HomeIcon};
|
||||
const faq = {name: 'FAQ', href: '/faq', icon: SolidQuestionIcon};
|
||||
const About = {name: 'About', href: '/about', icon: QuestionMarkCircleIcon};
|
||||
const Signin = {name: 'Sign in', href: '/signin', icon: UserCircleIcon};
|
||||
const Notifs = {name: 'Notifs', href: `/notifications`, icon: NotificationsIcon};
|
||||
const NotifsSolid = {name: 'Notifs', href: `/notifications`, icon: SolidNotificationsIcon};
|
||||
const Messages = {name: 'Messages', href: '/messages', icon: PrivateMessagesIcon};
|
||||
const Social = {name: 'Social', href: '/social', icon: LinkIcon};
|
||||
const Organization = {name: 'Organization', href: '/organization', icon: GlobeAltIcon};
|
||||
|
||||
const base = [
|
||||
About,
|
||||
faq,
|
||||
Social,
|
||||
Organization,
|
||||
]
|
||||
|
||||
function getBottomNavigation(user: User, profile: Profile | null | undefined) {
|
||||
return buildArray(
|
||||
@@ -125,33 +140,28 @@ function getBottomNavigation(user: User, profile: Profile | null | undefined) {
|
||||
)
|
||||
}
|
||||
|
||||
const signedOutNavigation = () => [
|
||||
const getBottomSignedOutNavigation = () => [
|
||||
Profiles,
|
||||
About,
|
||||
faq,
|
||||
Signin,
|
||||
]
|
||||
const getDesktopNav = (user: User | null | undefined) => {
|
||||
|
||||
const getDesktopNavigation = (user: User | null | undefined) => {
|
||||
if (user)
|
||||
return buildArray(
|
||||
ProfilesHome,
|
||||
Notifs,
|
||||
Messages,
|
||||
About,
|
||||
faq
|
||||
...base,
|
||||
)
|
||||
|
||||
return buildArray(
|
||||
// { name: 'Profiles', href: '/', icon: HomeIcon },
|
||||
About,
|
||||
faq
|
||||
...base
|
||||
)
|
||||
}
|
||||
|
||||
// No sidebar when signed out
|
||||
const getSidebarNavigation = (_toggleModal: () => void) => {
|
||||
const getMobileSidebar = (_toggleModal: () => void) => {
|
||||
return buildArray(
|
||||
About,
|
||||
faq
|
||||
...base,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Row } from './layout/row'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export const MultipleOrSingleAvatars = (props: {
|
||||
avatars: Array<{ avatarUrl: string; id: string }>
|
||||
avatars: Array<{ avatarUrl: string; id: string; name: string; username: string }>
|
||||
onClick?: () => void
|
||||
size: AvatarSizeType
|
||||
// TODO: standardize these numbers so they are calculated from the size
|
||||
@@ -17,7 +17,7 @@ export const MultipleOrSingleAvatars = (props: {
|
||||
if (avatars.length === 0) return null
|
||||
|
||||
if (avatars.length === 1) {
|
||||
return <Avatar size={size} avatarUrl={avatars[0].avatarUrl} />
|
||||
return <Avatar size={size} avatarUrl={avatars[0].avatarUrl} username={avatars[0].username} />
|
||||
}
|
||||
|
||||
const totalAvatars = avatars.length
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
import { MenuAlt3Icon } from '@heroicons/react/solid'
|
||||
import { Transition, Dialog } from '@headlessui/react'
|
||||
import { useState, Fragment } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import {MenuAlt3Icon} from '@heroicons/react/solid'
|
||||
import {Dialog, Transition} from '@headlessui/react'
|
||||
import {Fragment, useState} from 'react'
|
||||
import {useRouter} from 'next/router'
|
||||
import Sidebar from './love-sidebar'
|
||||
import { Item } from './love-sidebar-item'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Avatar } from 'web/components/widgets/avatar'
|
||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { User } from 'common/user'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useProfile } from 'web/hooks/use-profile'
|
||||
import {Item} from './love-sidebar-item'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {Avatar} from 'web/components/widgets/avatar'
|
||||
import {useIsIframe} from 'web/hooks/use-is-iframe'
|
||||
import {trackCallback} from 'web/lib/service/analytics'
|
||||
import {User} from 'common/user'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {useProfile} from 'web/hooks/use-profile'
|
||||
|
||||
const itemClass =
|
||||
'sm:hover:bg-ink-200 block w-full py-1 px-3 text-center sm:hover:text-primary-700 transition-colors'
|
||||
@@ -24,7 +24,7 @@ export function BottomNavBar(props: {
|
||||
navigationOptions: Item[]
|
||||
sidebarNavigationOptions: Item[]
|
||||
}) {
|
||||
const { navigationOptions, sidebarNavigationOptions } = props
|
||||
const {navigationOptions, sidebarNavigationOptions} = props
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const router = useRouter()
|
||||
@@ -38,7 +38,8 @@ export function BottomNavBar(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="border-ink-200 dark:border-ink-300 text-ink-700 bg-canvas-50 fixed inset-x-0 bottom-0 z-50 flex select-none items-center justify-between border-t-2 text-xs lg:hidden sidebar-nav">
|
||||
<nav
|
||||
className="border-ink-200 dark:border-ink-300 text-ink-700 bg-canvas-50 fixed inset-x-0 bottom-0 z-50 flex select-none items-center justify-between border-t-2 text-xs lg:hidden sidebar-nav">
|
||||
{navigationOptions.map((item) => (
|
||||
<NavBarItem
|
||||
key={item.name}
|
||||
@@ -47,26 +48,22 @@ export function BottomNavBar(props: {
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
{!!user && (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
itemClass,
|
||||
'relative',
|
||||
sidebarOpen ? selectedItemClass : ''
|
||||
)}
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<MenuAlt3Icon className="mx-auto my-1 h-6 w-6" aria-hidden="true" />
|
||||
More
|
||||
</div>
|
||||
<MobileSidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
sidebarNavigationOptions={sidebarNavigationOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
itemClass,
|
||||
'relative',
|
||||
sidebarOpen ? selectedItemClass : ''
|
||||
)}
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<MenuAlt3Icon className="mx-auto my-1 h-6 w-6" aria-hidden="true"/>
|
||||
More
|
||||
</div>
|
||||
<MobileSidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
sidebarNavigationOptions={sidebarNavigationOptions}
|
||||
/>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -79,7 +76,7 @@ function ProfileItem(props: {
|
||||
currentPage: string
|
||||
track: () => void
|
||||
}) {
|
||||
const { user, item, touched, setTouched, currentPage, track } = props
|
||||
const {user, item, touched, setTouched, currentPage, track} = props
|
||||
const profile = useProfile()
|
||||
return (
|
||||
<Link
|
||||
@@ -114,7 +111,7 @@ function NavBarItem(props: {
|
||||
user?: User | null
|
||||
className?: string
|
||||
}) {
|
||||
const { item, currentPage, children, user } = props
|
||||
const {item, currentPage, children, user} = props
|
||||
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||
const [touched, setTouched] = useState(false)
|
||||
if (item.name === 'Profile' && user) {
|
||||
@@ -141,7 +138,7 @@ function NavBarItem(props: {
|
||||
onTouchStart={() => setTouched(true)}
|
||||
onTouchEnd={() => setTouched(false)}
|
||||
>
|
||||
{item.icon && <item.icon className="mx-auto my-1 h-6 w-6" />}
|
||||
{item.icon && <item.icon className="mx-auto my-1 h-6 w-6"/>}
|
||||
{children}
|
||||
{item.name}
|
||||
</button>
|
||||
@@ -163,7 +160,7 @@ function NavBarItem(props: {
|
||||
onTouchStart={() => setTouched(true)}
|
||||
onTouchEnd={() => setTouched(false)}
|
||||
>
|
||||
{item.icon && <item.icon className="mx-auto my-1 h-6 w-6" />}
|
||||
{item.icon && <item.icon className="mx-auto my-1 h-6 w-6"/>}
|
||||
{children}
|
||||
{item.name}
|
||||
</Link>
|
||||
@@ -176,7 +173,7 @@ export function MobileSidebar(props: {
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
sidebarNavigationOptions: Item[]
|
||||
}) {
|
||||
const { sidebarOpen, sidebarNavigationOptions, setSidebarOpen } = props
|
||||
const {sidebarOpen, sidebarNavigationOptions, setSidebarOpen} = props
|
||||
return (
|
||||
<div>
|
||||
<Transition.Root show={sidebarOpen} as={Fragment}>
|
||||
@@ -195,7 +192,7 @@ export function MobileSidebar(props: {
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
{/* background cover */}
|
||||
<Dialog.Overlay className="bg-canvas-100/75 fixed inset-0" />
|
||||
<Dialog.Overlay className="bg-canvas-100/75 fixed inset-0"/>
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const OptionalLoveUserForm = (props: {
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
const {bio: _, ...otherProfileProps} = profile
|
||||
console.log('otherProfileProps', removeNullOrUndefinedProps(otherProfileProps))
|
||||
console.debug('otherProfileProps', removeNullOrUndefinedProps(otherProfileProps))
|
||||
const {error} = await tryCatch(
|
||||
updateProfile(removeNullOrUndefinedProps(otherProfileProps) as any)
|
||||
)
|
||||
|
||||
@@ -67,7 +67,7 @@ export const ProfileGrid = (props: {
|
||||
{!isLoadingMore && !isReloading && other_profiles.length === 0 && (
|
||||
<div className="py-8 text-center">
|
||||
<p>No profiles found.</p>
|
||||
<p>Feel free to bookmark your search and we'll notify you when new users match it!</p>
|
||||
<p>Feel free to click on Get Notified and we'll notify you when new users match it!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ProfileHeader(props: {
|
||||
const isCurrentUser = currentUser?.id === user.id
|
||||
const [showVisibilityModal, setShowVisibilityModal] = useState(false)
|
||||
|
||||
console.log('ProfileProfileHeader', {user, profile, currentUser})
|
||||
console.debug('ProfileProfileHeader', {user, profile, currentUser})
|
||||
|
||||
return (
|
||||
<Col className="w-full">
|
||||
@@ -144,13 +144,13 @@ export default function ProfileHeader(props: {
|
||||
className="sm:flex"
|
||||
username={user.username}
|
||||
/>
|
||||
{currentUser && (
|
||||
<StarButton
|
||||
targetProfile={profile}
|
||||
isStarred={starredUserIds.includes(user.id)}
|
||||
refresh={refreshStars}
|
||||
/>
|
||||
)}
|
||||
{/*{currentUser && (*/}
|
||||
{/* <StarButton*/}
|
||||
{/* targetProfile={profile}*/}
|
||||
{/* isStarred={starredUserIds.includes(user.id)}*/}
|
||||
{/* refresh={refreshStars}*/}
|
||||
{/* />*/}
|
||||
{/*)}*/}
|
||||
{currentUser && showMessageButton && (
|
||||
<SendMessageButton toUser={user} currentUser={currentUser}/>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ProfileInfo(props: {
|
||||
fromSignup?: boolean
|
||||
}) {
|
||||
const {profile, user, refreshProfile, fromProfilePage, fromSignup} = props
|
||||
console.log('Rendering Profile for', user.username, user.name, props)
|
||||
console.debug('Rendering Profile for', user.username, user.name, props)
|
||||
|
||||
const currentUser = useUser()
|
||||
// const currentProfile = useProfile()
|
||||
@@ -79,6 +79,7 @@ export function ProfileInfo(props: {
|
||||
refreshProfile={refreshProfile}
|
||||
fromProfilePage={fromProfilePage}
|
||||
fromSignup={fromSignup}
|
||||
isProfileVisible={isProfileVisible}
|
||||
// likesGiven={likesGiven ?? []}
|
||||
// likesReceived={likesReceived ?? []}
|
||||
// ships={ships ?? []}
|
||||
@@ -119,7 +120,6 @@ export function ProfileInfo(props: {
|
||||
{/* />*/}
|
||||
{/* </Row>*/}
|
||||
{/* )}*/}
|
||||
{isProfileVisible && profile.photo_urls && <ProfileCarousel profile={profile}/>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -130,6 +130,7 @@ function ProfileContent(props: {
|
||||
refreshProfile: () => void
|
||||
fromProfilePage?: Profile
|
||||
fromSignup?: boolean
|
||||
isProfileVisible?: true | User
|
||||
// likesGiven: LikeData[]
|
||||
// likesReceived: LikeData[]
|
||||
// ships: ShipData[]
|
||||
@@ -141,6 +142,7 @@ function ProfileContent(props: {
|
||||
refreshProfile,
|
||||
fromProfilePage,
|
||||
fromSignup,
|
||||
isProfileVisible
|
||||
// likesGiven,
|
||||
// likesReceived,
|
||||
// ships,
|
||||
@@ -166,6 +168,7 @@ function ProfileContent(props: {
|
||||
fromProfilePage={fromProfilePage}
|
||||
profile={profile}
|
||||
/>
|
||||
{isProfileVisible && profile.photo_urls && <ProfileCarousel profile={profile}/>}
|
||||
<ProfileCommentSection
|
||||
onUser={user}
|
||||
profile={profile}
|
||||
|
||||
@@ -4,9 +4,7 @@ import {Search} from 'web/components/filters/search'
|
||||
import {useProfile} from 'web/hooks/use-profile'
|
||||
import {useCompatibleProfiles} from 'web/hooks/use-profiles'
|
||||
import {getStars} from 'web/lib/supabase/stars'
|
||||
import Router from 'next/router'
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {ProfileGrid} from 'web/components/profile-grid'
|
||||
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {Title} from 'web/components/widgets/title'
|
||||
@@ -20,8 +18,7 @@ import {useFilters} from "web/components/filters/use-filters";
|
||||
|
||||
export function ProfilesHome() {
|
||||
const user = useUser();
|
||||
const profile = useProfile();
|
||||
const you = profile;
|
||||
const you = useProfile();
|
||||
|
||||
const {
|
||||
filters,
|
||||
@@ -95,7 +92,7 @@ export function ProfilesHome() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!profile && <Button className="mb-4 lg:hidden" onClick={() => Router.push('signup')}>Create a profile</Button>}
|
||||
{/*{user && !profile && <Button className="mb-4 lg:hidden" onClick={() => Router.push('signup')}>Create a profile</Button>}*/}
|
||||
<Title className="!mb-2 text-3xl">Profiles</Title>
|
||||
<Search
|
||||
youProfile={you}
|
||||
|
||||
@@ -141,7 +141,7 @@ const QuestionRow = (props: { row: rowFor<'love_questions'>; user: User }) => {
|
||||
className={'w-44'}
|
||||
choicesMap={options}
|
||||
setChoice={(choice) => {
|
||||
// console.log(choice)
|
||||
// console.debug(choice)
|
||||
const updatedForm = { ...form, multiple_choice: choice }
|
||||
setForm(updatedForm)
|
||||
submitAnswer(updatedForm)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useEffect} from 'react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Title} from 'web/components/widgets/title'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import clsx from 'clsx'
|
||||
@@ -12,7 +12,7 @@ import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {Column} from 'common/supabase/utils'
|
||||
import {ProfileRow} from 'common/love/profile'
|
||||
import {SignupBio} from "web/components/bio/editable-bio";
|
||||
import {JSONContent} from "@tiptap/core";
|
||||
import {Editor} from "@tiptap/core";
|
||||
|
||||
export const initialRequiredState = {
|
||||
age: undefined,
|
||||
@@ -49,6 +49,8 @@ export const RequiredLoveUserForm = (props: {
|
||||
const {user, onSubmit, profileCreatedAlready, setProfile, profile, isSubmitting} = props
|
||||
const {updateUsername, updateDisplayName, userInfo, updateUserState} = useEditableUserInfo(user)
|
||||
|
||||
const [step, setStep] = useState<number>(0)
|
||||
|
||||
const {
|
||||
name,
|
||||
username,
|
||||
@@ -83,56 +85,58 @@ export const RequiredLoveUserForm = (props: {
|
||||
return (
|
||||
<>
|
||||
<Title>The Basics</Title>
|
||||
{!profileCreatedAlready && <div className="text-ink-500 mb-6 text-lg">No endless forms—write your own bio, your own way.</div>}
|
||||
{step === 1 && !profileCreatedAlready &&
|
||||
<div className="text-ink-500 mb-6 text-lg">No endless forms—write your own bio, your own way.</div>}
|
||||
<Col className={'gap-8'}>
|
||||
<Col>
|
||||
<label className={clsx(labelClassName)}>Display name</label>
|
||||
<Row className={'items-center gap-2'}>
|
||||
<Input
|
||||
disabled={loadingName}
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
updateUserState({name: e.target.value || ''})
|
||||
}}
|
||||
onBlur={updateDisplayName}
|
||||
/>
|
||||
{loadingName && <LoadingIndicator className={'ml-2'}/>}
|
||||
</Row>
|
||||
{step === 0 && <Col>
|
||||
<label className={clsx(labelClassName)}>Display name</label>
|
||||
<Row className={'items-center gap-2'}>
|
||||
<Input
|
||||
disabled={loadingName}
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
updateUserState({name: e.target.value || ''})
|
||||
}}
|
||||
onBlur={updateDisplayName}
|
||||
/>
|
||||
{loadingName && <LoadingIndicator className={'ml-2'}/>}
|
||||
</Row>
|
||||
{errorName && <span className="text-error text-sm">{errorName}</span>}
|
||||
</Col>
|
||||
</Col>}
|
||||
|
||||
{!profileCreatedAlready && <>
|
||||
<Col>
|
||||
<label className={clsx(labelClassName)}>Username</label>
|
||||
<Row className={'items-center gap-2'}>
|
||||
<Input
|
||||
disabled={loadingUsername}
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
updateUserState({username: e.target.value || ''})
|
||||
}}
|
||||
onBlur={updateUsername}
|
||||
/>
|
||||
{loadingUsername && <LoadingIndicator className={'ml-2'}/>}
|
||||
</Row>
|
||||
{errorUsername && (
|
||||
<span className="text-error text-sm">{errorUsername}</span>
|
||||
)}
|
||||
</Col>
|
||||
{step === 0 && <Col>
|
||||
<label className={clsx(labelClassName)}>Username</label>
|
||||
<Row className={'items-center gap-2'}>
|
||||
<Input
|
||||
disabled={loadingUsername}
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
updateUserState({username: e.target.value || ''})
|
||||
}}
|
||||
onBlur={updateUsername}
|
||||
/>
|
||||
{loadingUsername && <LoadingIndicator className={'ml-2'}/>}
|
||||
</Row>
|
||||
{errorUsername && (
|
||||
<span className="text-error text-sm">{errorUsername}</span>
|
||||
)}
|
||||
</Col>}
|
||||
|
||||
<Col>
|
||||
<label className={clsx(labelClassName)}>Bio</label>
|
||||
<SignupBio
|
||||
onChange={(e: JSONContent) => {
|
||||
console.log('bio changed', e, profile.bio)
|
||||
setProfile('bio', e)
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
{step === 1 && <Col>
|
||||
<label className={clsx(labelClassName)}>Bio</label>
|
||||
<SignupBio
|
||||
onChange={(e: Editor) => {
|
||||
console.debug('bio changed', e, profile.bio)
|
||||
setProfile('bio', e.getJSON())
|
||||
setProfile('bio_length', e.getText().length)
|
||||
}}
|
||||
/>
|
||||
</Col>}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -141,7 +145,13 @@ export const RequiredLoveUserForm = (props: {
|
||||
<Button
|
||||
disabled={!canContinue || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={onSubmit}
|
||||
onClick={() => {
|
||||
if (step === 1) {
|
||||
onSubmit()
|
||||
} else {
|
||||
setStep(1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from 'web/lib/api'
|
||||
import { countryCodeToFlag } from 'web/lib/util/location'
|
||||
import { ProfileRow } from 'common/love/profile'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {api} from 'web/lib/api'
|
||||
import {countryCodeToFlag} from 'web/lib/util/location'
|
||||
import {ProfileRow} from 'common/love/profile'
|
||||
import {OriginLocation} from "common/filters";
|
||||
|
||||
function isDigitString(value: string): boolean {
|
||||
return /^\d+$/.test(value);
|
||||
}
|
||||
|
||||
export type City = {
|
||||
geodb_city_id: string
|
||||
city: string
|
||||
@@ -44,7 +48,7 @@ export function CityRow(props: {
|
||||
onSelect: (city: City) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { city, onSelect, className } = props
|
||||
const {city, onSelect, className} = props
|
||||
return (
|
||||
<button
|
||||
key={city.geodb_city_id}
|
||||
@@ -56,7 +60,7 @@ export function CityRow(props: {
|
||||
>
|
||||
<div className="group-hover:text-ink-950 font-semibold transition-colors">
|
||||
{city.city}
|
||||
{city.region_code ? `, ${city.region_code}` : ''}
|
||||
{city.region_code && !isDigitString(city.region_code) ? `, ${city.region_code}` : ''}
|
||||
</div>
|
||||
<div className="text-ink-400 group-hover:text-ink-700 transition-colors">
|
||||
{countryCodeToFlag(city.country_code) || city.country}
|
||||
@@ -78,7 +82,7 @@ export const useCitySearch = () => {
|
||||
setLoading(true)
|
||||
searchCountRef.current++
|
||||
const thisSearchCount = searchCountRef.current
|
||||
const response = await api('search-location', { term: query, limit: 5 })
|
||||
const response = await api('search-location', {term: query, limit: 8})
|
||||
if (response.status !== 'success') {
|
||||
throw new Error(response.data)
|
||||
}
|
||||
@@ -106,16 +110,15 @@ export const useCitySearch = () => {
|
||||
if (query.length < 2) {
|
||||
setCities([])
|
||||
return
|
||||
}
|
||||
if (query.length >= 2) {
|
||||
} else {
|
||||
fetchData()
|
||||
}
|
||||
}, 200)
|
||||
}, 400)
|
||||
|
||||
return () => {
|
||||
clearTimeout(debounce)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
return { query, setQuery, loading, cities }
|
||||
return {query, setQuery, loading, cities}
|
||||
}
|
||||
|
||||
@@ -43,14 +43,11 @@ export const Avatar = memo(
|
||||
Router.push(`/${username}`)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackInitial = (username || 'U')[0]; // first character, not encoded string
|
||||
const url: string = avatarUrl && avatarUrl.length > 0
|
||||
? avatarUrl
|
||||
: `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackInitial)}`;
|
||||
|
||||
|
||||
// console.log(url)
|
||||
// console.log(username, fallbackInitial, url)
|
||||
|
||||
// there can be no avatar URL or username in the feed, we show a "submit comment"
|
||||
// item with a fake grey user circle guy even if you aren't signed in
|
||||
|
||||
@@ -1,46 +1,108 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts";
|
||||
import {getUserCreations} from "web/lib/supabase/users";
|
||||
import {Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts";
|
||||
import {getProfilesCreations, getProfilesWithBioCreations} from "web/lib/supabase/users";
|
||||
|
||||
export default function ChartComponent() {
|
||||
const [data, setData] = useState([]);
|
||||
// Helper to convert rows into date -> count map
|
||||
function buildCounts(rows: any[]) {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const r of rows) {
|
||||
const date = new Date(r.created_time).toISOString().split('T')[0]
|
||||
counts[date] = (counts[date] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// Helper to turn count map into cumulative by sorted date array
|
||||
function cumulativeFromCounts(counts: Record<string, number>, sortedDates: string[]) {
|
||||
const out: Record<string, number> = {}
|
||||
let prev = 0
|
||||
for (const d of sortedDates) {
|
||||
const v = counts[d] || 0
|
||||
prev += v
|
||||
out[d] = prev
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
export default function ChartMembers() {
|
||||
const [data, setData] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
// Load some data from the backend API or Supabase
|
||||
const data = await getUserCreations()
|
||||
const counts: { [date: string]: number } = {}
|
||||
data.forEach((d) => {
|
||||
const date = new Date(d.created_time).toISOString().split('T')[0]
|
||||
counts[date] = (counts[date] || 0) + 1
|
||||
})
|
||||
const json: any = Object.entries(counts).map(([date, value]) => ({date, value}))
|
||||
let prev = 0
|
||||
for (const e of json) {
|
||||
e.value += prev
|
||||
prev = e.value
|
||||
}
|
||||
json.sort((a: any, b: any) => a.date.localeCompare(b.date))
|
||||
async function load() {
|
||||
const [allProfiles, bioProfiles] = await Promise.all([
|
||||
getProfilesCreations(),
|
||||
getProfilesWithBioCreations(),
|
||||
])
|
||||
|
||||
// Example static data
|
||||
// const json: any = [
|
||||
// { date: '2023-01-01', value: 400 },
|
||||
// { date: '2023-02-01', value: 300 },
|
||||
// { date: '2023-03-01', value: 500 },
|
||||
// { date: '2023-04-01', value: 200 },
|
||||
// { date: '2023-05-01', value: 600 },
|
||||
// ]
|
||||
setData(json);
|
||||
const countsAll = buildCounts(allProfiles)
|
||||
const countsBio = buildCounts(bioProfiles)
|
||||
|
||||
// Build a full daily date range from min to max date for equidistant time axis
|
||||
const allDates = Object.keys(countsAll)
|
||||
const bioDates = Object.keys(countsBio)
|
||||
const minDateStr = [
|
||||
...allDates,
|
||||
...bioDates,
|
||||
].sort((a, b) => a.localeCompare(b))[0]
|
||||
const maxDateStr = [
|
||||
...allDates,
|
||||
...bioDates,
|
||||
].sort((a, b) => b.localeCompare(a))[0]
|
||||
|
||||
function toISODate(d: Date) {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number) {
|
||||
const nd = new Date(d)
|
||||
nd.setUTCDate(nd.getUTCDate() + days)
|
||||
return nd
|
||||
}
|
||||
|
||||
function buildDailyRange(startStr: string, endStr: string) {
|
||||
const out: string[] = []
|
||||
const start = new Date(startStr + 'T00:00:00.000Z')
|
||||
const end = new Date(endStr + 'T00:00:00.000Z')
|
||||
for (let d = start; d <= end; d = addDays(d, 1)) {
|
||||
out.push(toISODate(d))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const dates = buildDailyRange(minDateStr, maxDateStr)
|
||||
|
||||
const cumAll = cumulativeFromCounts(countsAll, dates)
|
||||
const cumBio = cumulativeFromCounts(countsBio, dates)
|
||||
|
||||
const merged = dates.map((date) => ({
|
||||
date,
|
||||
dateTs: new Date(date + 'T00:00:00.000Z').getTime(),
|
||||
profilesCreations: cumAll[date] || 0,
|
||||
profilesWithBioCreations: cumBio[date] || 0,
|
||||
}))
|
||||
|
||||
setData(merged)
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
// One LineChart with two Line series sharing the same data array
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3"/>
|
||||
<XAxis dataKey="date" label={{value: "Date", position: "insideBottomRight", offset: -5}}/>
|
||||
{/*<CartesianGrid strokeDasharray="3 3"/>*/}
|
||||
<XAxis
|
||||
dataKey="dateTs"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts) => new Date(ts).toISOString().split("T")[0]}
|
||||
label={{value: "Date", position: "insideBottomRight", offset: -5}}
|
||||
/>
|
||||
<YAxis label={{value: "Number of Members", angle: -90, position: "insideLeft"}}/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
@@ -52,14 +114,27 @@ export default function ChartComponent() {
|
||||
labelStyle={{
|
||||
color: "rgb(var(--color-primary-900))",
|
||||
}}
|
||||
labelFormatter={(value, payload) => (payload && payload[0] && payload[0].payload?.date) || new Date(value as number).toISOString().split("T")[0]}
|
||||
/>
|
||||
<Legend/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="profilesCreations"
|
||||
name="Total"
|
||||
stroke="rgb(var(--color-primary-900))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="rgb(var(--color-primary-900))"
|
||||
dataKey="profilesWithBioCreations"
|
||||
name="With Bio"
|
||||
stroke="#9ca3af"
|
||||
strokeDasharray="4 2"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import {Link} from '@tiptap/extension-link'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import type { Content, JSONContent } from '@tiptap/react'
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
Extensions,
|
||||
mergeAttributes,
|
||||
useEditor,
|
||||
} from '@tiptap/react'
|
||||
import type {Content, JSONContent} from '@tiptap/react'
|
||||
import {Editor, EditorContent, Extensions, mergeAttributes, useEditor,} from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import clsx from 'clsx'
|
||||
import { ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { DisplayMention } from '../editor/user-mention/mention-extension'
|
||||
import { Linkify } from './linkify'
|
||||
import { linkClass } from './site-link'
|
||||
import {ReactNode, useCallback, useEffect, useMemo} from 'react'
|
||||
import {DisplayMention} from '../editor/user-mention/mention-extension'
|
||||
import {Linkify} from './linkify'
|
||||
import {linkClass} from './site-link'
|
||||
import Iframe from 'common/util/tiptap-iframe'
|
||||
import { debounce, noop } from 'lodash'
|
||||
import { FloatingFormatMenu } from '../editor/floating-format-menu'
|
||||
import { StickyFormatMenu } from '../editor/sticky-format-menu'
|
||||
import { Upload, useUploadMutation } from '../editor/upload-extension'
|
||||
import { generateReact } from '../editor/utils'
|
||||
import { EmojiExtension } from '../editor/emoji/emoji-extension'
|
||||
import { nodeViewMiddleware } from '../editor/nodeview-middleware'
|
||||
import { BasicImage, DisplayImage, MediumDisplayImage } from '../editor/image'
|
||||
import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
import { safeLocalStorage } from 'web/lib/util/local'
|
||||
import {debounce, noop} from 'lodash'
|
||||
import {FloatingFormatMenu} from '../editor/floating-format-menu'
|
||||
import {StickyFormatMenu} from '../editor/sticky-format-menu'
|
||||
import {Upload, useUploadMutation} from '../editor/upload-extension'
|
||||
import {generateReact} from '../editor/utils'
|
||||
import {EmojiExtension} from '../editor/emoji/emoji-extension'
|
||||
import {nodeViewMiddleware} from '../editor/nodeview-middleware'
|
||||
import {BasicImage, DisplayImage, MediumDisplayImage} from '../editor/image'
|
||||
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
|
||||
import {richTextToString} from 'common/util/parse'
|
||||
import {safeLocalStorage} from 'web/lib/util/local'
|
||||
|
||||
const DisplayLink = Link.extend({
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
renderHTML({HTMLAttributes}) {
|
||||
HTMLAttributes.target = HTMLAttributes.href.includes('manifold.markets')
|
||||
? '_self'
|
||||
: '_blank'
|
||||
@@ -51,7 +45,7 @@ const DisplayLink = Link.extend({
|
||||
const editorExtensions = (simple = false): Extensions =>
|
||||
nodeViewMiddleware([
|
||||
StarterKit.configure({
|
||||
heading: simple ? false : { levels: [1, 2, 3] },
|
||||
heading: simple ? false : {levels: [1, 2, 3]},
|
||||
horizontalRule: simple ? false : {},
|
||||
}),
|
||||
simple ? DisplayImage : BasicImage,
|
||||
@@ -84,7 +78,7 @@ export function useTextEditor(props: {
|
||||
extensions?: Extensions
|
||||
className?: string
|
||||
}) {
|
||||
const { placeholder, className, max, defaultValue, size = 'md', key } = props
|
||||
const {placeholder, className, max, defaultValue, size = 'md', key} = props
|
||||
const simple = size === 'sm'
|
||||
|
||||
const [content, setContent] = usePersistentLocalState<
|
||||
@@ -122,9 +116,9 @@ export function useTextEditor(props: {
|
||||
editorProps: getEditorProps(),
|
||||
onUpdate: !key
|
||||
? noop
|
||||
: ({ editor }) => {
|
||||
save(editor.getJSON())
|
||||
},
|
||||
: ({editor}) => {
|
||||
save(editor.getJSON())
|
||||
},
|
||||
extensions: [
|
||||
...editorExtensions(simple),
|
||||
Placeholder.configure({
|
||||
@@ -132,7 +126,7 @@ export function useTextEditor(props: {
|
||||
emptyEditorClass:
|
||||
'before:content-[attr(data-placeholder)] before:text-ink-500 before:float-left before:h-0 cursor-text',
|
||||
}),
|
||||
CharacterCount.configure({ limit: max }),
|
||||
CharacterCount.configure({limit: max}),
|
||||
...(props.extensions ?? []),
|
||||
],
|
||||
content: defaultValue ?? (key && content ? content : ''),
|
||||
@@ -186,7 +180,7 @@ export function TextEditor(props: {
|
||||
onBlur?: () => void
|
||||
onChange?: () => void
|
||||
}) {
|
||||
const { editor, simple, hideEmbed, children, className, onBlur, onChange } = props
|
||||
const {editor, simple, hideEmbed, children, className, onBlur, onChange} = props
|
||||
|
||||
return (
|
||||
// matches input styling
|
||||
@@ -196,9 +190,9 @@ export function TextEditor(props: {
|
||||
className
|
||||
)}
|
||||
>
|
||||
<FloatingFormatMenu editor={editor} advanced={!simple} />
|
||||
<FloatingFormatMenu editor={editor} advanced={!simple}/>
|
||||
<div className={clsx('max-h-[69vh] overflow-auto')}>
|
||||
<EditorContent editor={editor} onBlur={onBlur} onChange={onChange} />
|
||||
<EditorContent editor={editor} onBlur={onBlur} onChange={onChange}/>
|
||||
</div>
|
||||
|
||||
<StickyFormatMenu editor={editor} hideEmbed={hideEmbed}>
|
||||
@@ -213,7 +207,7 @@ function RichContent(props: {
|
||||
className?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}) {
|
||||
const { className, content, size = 'md' } = props
|
||||
const {className, content, size = 'md'} = props
|
||||
|
||||
const jsxContent = useMemo(() => {
|
||||
try {
|
||||
@@ -222,8 +216,8 @@ function RichContent(props: {
|
||||
size === 'sm'
|
||||
? DisplayImage
|
||||
: size === 'md'
|
||||
? MediumDisplayImage
|
||||
: BasicImage,
|
||||
? MediumDisplayImage
|
||||
: BasicImage,
|
||||
DisplayLink,
|
||||
DisplayMention,
|
||||
Iframe,
|
||||
@@ -257,7 +251,7 @@ export function Content(props: {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}) {
|
||||
const { className, size = 'md', content } = props
|
||||
const {className, size = 'md', content} = props
|
||||
return typeof content === 'string' ? (
|
||||
<Linkify
|
||||
className={clsx('whitespace-pre-line', proseClass(size), className)}
|
||||
|
||||
@@ -125,7 +125,7 @@ const UserAvatar = (props: { userId: string; className?: string }) => {
|
||||
const profile = useProfileByUserId(userId)
|
||||
const user = useUserById(userId)
|
||||
|
||||
// console.log('UserAvatar', user?.username, profile?.pinned_url)
|
||||
// console.debug('UserAvatar', user?.username, profile?.pinned_url)
|
||||
|
||||
if (!profile)
|
||||
return <EmptyAvatar className={className} size={10} />
|
||||
|
||||
@@ -19,12 +19,12 @@ export function useNearbyCities(
|
||||
cityId: referenceCityId,
|
||||
radius,
|
||||
}).then((result) => {
|
||||
console.log('search-near-city', result)
|
||||
console.debug('search-near-city', result)
|
||||
if (thisSearchCount == searchCount.current) {
|
||||
if (result.status === 'failure') {
|
||||
setNearbyCities(null)
|
||||
lastKnownCities.current = null
|
||||
console.log('ERROR:', result.data)
|
||||
console.error(result.data)
|
||||
} else {
|
||||
const cities = (result.data.data as any[]).map((city) =>
|
||||
city.id.toString()
|
||||
|
||||
@@ -22,7 +22,7 @@ export function usePrivateMessages(
|
||||
limit: number,
|
||||
userId: string
|
||||
) {
|
||||
console.log('getWebsocketUrl', getWebsocketUrl())
|
||||
// console.debug('getWebsocketUrl', getWebsocketUrl())
|
||||
const [messages, setMessages] = usePersistentLocalState<
|
||||
PrivateChatMessage[] | undefined
|
||||
>(undefined, `private-messages-${channelId}-${limit}-v1`)
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useProfile = () => {
|
||||
|
||||
const refreshProfile = () => {
|
||||
if (user) {
|
||||
console.log('Refreshing profile in useProfile for', user?.username, profile);
|
||||
console.debug('Refreshing profile in useProfile for', user?.username, profile);
|
||||
getProfileRow(user.id, db).then((profile) => {
|
||||
if (!profile) setProfile(null)
|
||||
else setProfile(profile)
|
||||
@@ -38,18 +38,18 @@ export const useProfileByUser = (user: User | undefined) => {
|
||||
|
||||
function refreshProfile() {
|
||||
if (userId) {
|
||||
console.log('Refreshing profile in useProfileByUser for', user?.username, profile);
|
||||
console.debug('Refreshing profile in useProfileByUser for', user?.username, profile);
|
||||
getProfileRow(userId, db)
|
||||
.then((profile) => {
|
||||
if (!profile) setProfile(null)
|
||||
else setProfile({...profile, user})
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Warning: profile not found', user?.username, error);
|
||||
console.debug('Warning: profile not found', user?.username, error);
|
||||
setProfile(null)
|
||||
return
|
||||
});
|
||||
console.log('End Refreshing profile for', user?.username, profile);
|
||||
console.debug('End Refreshing profile for', user?.username, profile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export const useProfileByUserId = (userId: string | undefined) => {
|
||||
>(undefined, `profile-${userId}`)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Refreshing profile in useProfileByUserId for', userId, profile);
|
||||
console.debug('Refreshing profile in useProfileByUserId for', userId, profile);
|
||||
if (userId)
|
||||
getProfileRow(userId, db).then((profile) => {
|
||||
if (!profile) setProfile(null)
|
||||
|
||||
@@ -29,7 +29,7 @@ export const useWebsocketUser = (userId: string | undefined) => {
|
||||
useApiSubscription({
|
||||
topics: [`user/${userId ?? '_'}`],
|
||||
onBroadcast: ({ data }) => {
|
||||
console.log(data)
|
||||
console.debug(data)
|
||||
setUser((user) => {
|
||||
if (!user || !data.user) {
|
||||
return user
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user