mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51:45 -04:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
204a35d026 | ||
|
|
fb2841f198 | ||
|
|
5de055c977 | ||
|
|
084659ea3d | ||
|
|
c1a414afab | ||
|
|
a5747034d6 | ||
|
|
fda52fec97 | ||
|
|
e38ec79618 | ||
|
|
1ef125db12 | ||
|
|
b580b640bd | ||
|
|
214bddaca4 | ||
|
|
065d489869 | ||
|
|
46ffefbbb9 | ||
|
|
a19db3bca9 | ||
|
|
2c8d8d9989 | ||
|
|
d52943e31e | ||
|
|
3eababb742 | ||
|
|
8a954d3c20 | ||
|
|
8516901032 | ||
|
|
3f2d246fec | ||
|
|
58fdaa26ca | ||
|
|
7dc1a8790d | ||
|
|
70c9ec1d73 | ||
|
|
2bcbbc96ad | ||
|
|
527d36a159 | ||
|
|
2ce21247ee | ||
|
|
8ea6c406e0 | ||
|
|
e22f50ecd3 | ||
|
|
20dcd98fdf | ||
|
|
bc5708857a | ||
|
|
b9c045ebfb | ||
|
|
c69bd7018e | ||
|
|
078d149175 | ||
|
|
be9f0bd061 | ||
|
|
a4723563f5 | ||
|
|
1fdcd24f28 | ||
|
|
a43480db92 | ||
|
|
e85a072f1c | ||
|
|
bbfa2a4eab | ||
|
|
2f2db4ded8 | ||
|
|
7296a0d2cd | ||
|
|
08e02b6ac0 | ||
|
|
715811d7fd | ||
|
|
c7d6ae6995 | ||
|
|
b1d1396944 | ||
|
|
25a319710e | ||
|
|
796b13dd62 | ||
|
|
8197863ac5 | ||
|
|
89bd164d43 | ||
|
|
80d7061e5f | ||
|
|
c49bac3a09 | ||
|
|
06d53fe801 | ||
|
|
15ba529938 | ||
|
|
83054d0cd1 | ||
|
|
8da486adf2 | ||
|
|
32bc3847fa | ||
|
|
5d763c18c8 | ||
|
|
bd3920cfff | ||
|
|
06d94332b6 | ||
|
|
50614484d8 | ||
|
|
c29d3d8c92 | ||
|
|
26f46af375 | ||
|
|
32b1491dd0 | ||
|
|
51b8a6c80a | ||
|
|
0f63d6d3a0 | ||
|
|
4771b08773 | ||
|
|
9b880101fd | ||
|
|
594806d6e8 | ||
|
|
e9afd4db2f | ||
|
|
b23efe4089 | ||
|
|
e33be41a93 | ||
|
|
33b09df872 | ||
|
|
e9050d0aa0 | ||
|
|
baeb2a33fe | ||
|
|
4ad89acdc7 | ||
|
|
7d87af8f5c | ||
|
|
65c0e84e2a | ||
|
|
7b15d85871 | ||
|
|
ad8ec0f4fd | ||
|
|
2d05d83dd0 | ||
|
|
bd45066b13 | ||
|
|
8ee4274054 | ||
|
|
83a7ed4d6b | ||
|
|
07dbd86ac6 | ||
|
|
0e671d2cc0 | ||
|
|
2d6d3c04ce | ||
|
|
b0148963c7 | ||
|
|
13356950f3 | ||
|
|
629bcb30a7 | ||
|
|
03721fff1c | ||
|
|
2a6911ae3d |
10
README.md
10
README.md
@@ -152,6 +152,16 @@ Note: it's normal if page loading locally is much slower than the deployed versi
|
||||
|
||||
Now you can start contributing by making changes and submitting pull requests!
|
||||
|
||||
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
|
||||
- Components tab to see the React component tree and props (you need to install the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
||||
- Console tab for errors and logs
|
||||
- Network tab to see the requests and responses
|
||||
- Storage tab to see cookies and local storage
|
||||
|
||||
You can also add `console.log()` statements in the code.
|
||||
|
||||
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
|
||||
|
||||
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
|
||||
|
||||
### Submission
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"verify": "yarn --cwd=../.. verify",
|
||||
"verify:dir": "npx eslint . --max-warnings 0",
|
||||
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
|
||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -50,9 +50,15 @@ import {leavePrivateUserMessageChannel} from './leave-private-user-message-chann
|
||||
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
||||
import {getNotifications} from './get-notifications'
|
||||
import {updateNotifSettings} from './update-notif-setting'
|
||||
import {setLastOnlineTime} from './set-last-online-time'
|
||||
import swaggerUi from "swagger-ui-express"
|
||||
import * as fs from "fs"
|
||||
import {sendSearchNotifications} from "api/send-search-notifications";
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {getMessagesCount} from "api/get-messages-count";
|
||||
import {createVote} from "api/create-vote";
|
||||
import {vote} from "api/vote";
|
||||
import {contact} from "api/contact";
|
||||
|
||||
const allowCorsUnrestricted: RequestHandler = cors({})
|
||||
|
||||
@@ -153,6 +159,9 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'create-comment': createComment,
|
||||
'hide-comment': hideComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'create-vote': createVote,
|
||||
'vote': vote,
|
||||
'contact': contact,
|
||||
'compatible-profiles': getCompatibleProfilesHandler,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
@@ -164,6 +173,8 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'get-channel-messages': getChannelMessages,
|
||||
'get-channel-seen-time': getLastSeenChannelTime,
|
||||
'set-channel-seen-time': setChannelLastSeenTime,
|
||||
'get-messages-count': getMessagesCount,
|
||||
'set-last-online-time': setLastOnlineTime,
|
||||
}
|
||||
|
||||
Object.entries(handlers).forEach(([path, handler]) => {
|
||||
@@ -206,6 +217,10 @@ app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
console.error("Failed to send notifications:", err);
|
||||
await sendDiscordMessage(
|
||||
"Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...",
|
||||
"health"
|
||||
)
|
||||
return res.status(500).json({error: "Internal server error"});
|
||||
}
|
||||
}
|
||||
|
||||
41
backend/api/src/contact.ts
Normal file
41
backend/api/src/contact.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {jsonToMarkdown} from "common/md";
|
||||
|
||||
// Stores a contact message into the `contact` table
|
||||
// Web sends TipTap JSON in `content`; we store it as string in `description`.
|
||||
// If optional content metadata is provided, we include it; otherwise we fall back to user-centric defaults.
|
||||
export const contact: APIHandler<'contact'> = async (
|
||||
{content, userId},
|
||||
_auth
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const {error} = await tryCatch(
|
||||
insert(pg, 'contact', {
|
||||
user_id: userId,
|
||||
content: JSON.stringify(content),
|
||||
})
|
||||
)
|
||||
|
||||
if (error) throw new APIError(500, 'Failed to submit contact message')
|
||||
|
||||
const continuation = async () => {
|
||||
try {
|
||||
const md = jsonToMarkdown(content)
|
||||
const message: string = `**New Contact Message**\n${md}`
|
||||
await sendDiscordMessage(message, 'contact')
|
||||
} catch (e) {
|
||||
console.error('Failed to send discord contact', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
@@ -8,30 +8,7 @@ import { updateUser } from 'shared/supabase/users'
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
import {jsonToMarkdown} from "common/md";
|
||||
|
||||
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -75,8 +52,8 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
try {
|
||||
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}`
|
||||
const bioText = jsonToMarkdown(body.bio)
|
||||
if (bioText) message += `\n${bioText}`
|
||||
}
|
||||
await sendDiscordMessage(message, 'members')
|
||||
} catch (e) {
|
||||
|
||||
27
backend/api/src/create-vote.ts
Normal file
27
backend/api/src/create-vote.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIHandler, APIError } from './helpers/endpoint'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
|
||||
export const createVote: APIHandler<
|
||||
'create-vote'
|
||||
> = async ({ title, description, isAnonymous }, auth) => {
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'votes', {
|
||||
creator_id: creator.id,
|
||||
title,
|
||||
description,
|
||||
is_anonymous: isAnonymous,
|
||||
})
|
||||
)
|
||||
|
||||
if (error) throw new APIError(401, 'Error creating question')
|
||||
|
||||
return { data }
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
||||
await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
|
||||
await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
|
||||
await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
|
||||
await pg.none('DELETE FROM love_compatibility_answers WHERE creator_id = $1', [userId])
|
||||
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
|
||||
|
||||
// Delete user files from Firebase Storage
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getCompatibilityQuestions: APIHandler<
|
||||
> = async (_props, _auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const dbQuestions = await pg.manyOrNone<
|
||||
const questions = await pg.manyOrNone<
|
||||
Row<'love_questions'> & { answer_count: number; score: number }
|
||||
>(
|
||||
`SELECT
|
||||
@@ -31,13 +31,13 @@ export const getCompatibilityQuestions: APIHandler<
|
||||
love_questions.answer_type = 'compatibility_multiple_choice'
|
||||
GROUP BY
|
||||
love_questions.id
|
||||
ORDER BY
|
||||
score DESC
|
||||
ORDER BY
|
||||
love_questions.importance_score
|
||||
`,
|
||||
[]
|
||||
)
|
||||
|
||||
const questions = shuffle(dbQuestions)
|
||||
// const questions = shuffle(dbQuestions)
|
||||
|
||||
// console.debug(
|
||||
// 'got questions',
|
||||
|
||||
18
backend/api/src/get-messages-count.ts
Normal file
18
backend/api/src/get-messages-count.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||
|
||||
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const result = await pg.one(
|
||||
`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM private_user_messages;
|
||||
`,
|
||||
[]
|
||||
);
|
||||
const count = Number(result.count);
|
||||
console.debug('private_user_messages count:', count);
|
||||
return {
|
||||
count: count,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {convertRow} from 'shared/love/supabase'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {getCompatibleProfiles} from 'api/compatible-profiles'
|
||||
import {intersection} from 'lodash'
|
||||
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
|
||||
@@ -16,17 +16,23 @@ export type profileQueryType = {
|
||||
pref_age_min?: number | undefined,
|
||||
pref_age_max?: number | undefined,
|
||||
pref_relation_styles?: String[] | undefined,
|
||||
pref_romantic_styles?: String[] | undefined,
|
||||
wants_kids_strength?: number | undefined,
|
||||
has_kids?: number | undefined,
|
||||
is_smoker?: boolean | undefined,
|
||||
shortBio?: boolean | undefined,
|
||||
geodbCityIds?: String[] | undefined,
|
||||
lat?: number | undefined,
|
||||
lon?: number | undefined,
|
||||
radius?: number | undefined,
|
||||
compatibleWithUserId?: string | undefined,
|
||||
skipId?: string | undefined,
|
||||
orderBy?: string | undefined,
|
||||
lastModificationWithin?: string | undefined,
|
||||
}
|
||||
|
||||
const userActivityColumns = ['last_online_time']
|
||||
|
||||
|
||||
export const loadProfiles = async (props: profileQueryType) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -40,17 +46,23 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
pref_age_min,
|
||||
pref_age_max,
|
||||
pref_relation_styles,
|
||||
pref_romantic_styles,
|
||||
wants_kids_strength,
|
||||
has_kids,
|
||||
is_smoker,
|
||||
shortBio,
|
||||
geodbCityIds,
|
||||
lat,
|
||||
lon,
|
||||
radius,
|
||||
compatibleWithUserId,
|
||||
orderBy: orderByParam = 'created_time',
|
||||
lastModificationWithin,
|
||||
skipId,
|
||||
} = props
|
||||
|
||||
const filterLocation = lat && lon && radius
|
||||
|
||||
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
|
||||
// console.debug('keywords:', keywords)
|
||||
|
||||
@@ -71,6 +83,8 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
|
||||
(!pref_relation_styles ||
|
||||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
|
||||
(!pref_romantic_styles ||
|
||||
intersection(pref_romantic_styles, l.pref_romantic_styles).length) &&
|
||||
(!wants_kids_strength ||
|
||||
wants_kids_strength == -1 ||
|
||||
(wants_kids_strength >= 2
|
||||
@@ -84,7 +98,13 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(l.id.toString() != skipId) &&
|
||||
(!geodbCityIds ||
|
||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
|
||||
((l.bio_length ?? 0) >= MIN_BIO_LENGTH)
|
||||
(!filterLocation ||(
|
||||
l.city_latitude && l.city_longitude &&
|
||||
Math.abs(l.city_latitude - lat) < radius / 69.0 &&
|
||||
Math.abs(l.city_longitude - lon) < radius / (69.0 * Math.cos(lat * Math.PI / 180)) &&
|
||||
Math.pow(l.city_latitude - lat, 2) + Math.pow((l.city_longitude - lon) * Math.cos(lat * Math.PI / 180), 2) < Math.pow(radius / 69.0, 2)
|
||||
)) &&
|
||||
(shortBio || (l.bio_length ?? 0) >= MIN_BIO_LENGTH)
|
||||
)
|
||||
|
||||
const cursor = after
|
||||
@@ -97,10 +117,14 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
return profiles
|
||||
}
|
||||
|
||||
const tablePrefix = userActivityColumns.includes(orderByParam) ? 'user_activity' : 'profiles'
|
||||
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||
|
||||
const query = renderSql(
|
||||
select('profiles.*, name, username, users.data as user'),
|
||||
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
|
||||
from('profiles'),
|
||||
join('users on users.id = profiles.user_id'),
|
||||
leftJoin(userActivityJoin),
|
||||
where('looking_for_matches = true'),
|
||||
// where(`pinned_url is not null and pinned_url != ''`),
|
||||
where(
|
||||
@@ -127,7 +151,13 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
pref_relation_styles?.length &&
|
||||
where(
|
||||
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
|
||||
{ pref_relation_styles }
|
||||
{pref_relation_styles}
|
||||
),
|
||||
|
||||
pref_romantic_styles?.length &&
|
||||
where(
|
||||
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
|
||||
{pref_romantic_styles}
|
||||
),
|
||||
|
||||
!!wants_kids_strength &&
|
||||
@@ -147,12 +177,29 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
geodbCityIds?.length &&
|
||||
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
||||
|
||||
skipId && where(`user_id != $(skipId)`, {skipId}),
|
||||
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
|
||||
filterLocation && where(`
|
||||
city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0)
|
||||
AND $(target_lat) + ($(radius) / 69.0)
|
||||
AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||
AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||
AND SQRT(
|
||||
POWER(city_latitude - $(target_lat), 2)
|
||||
+ POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
|
||||
) <= $(radius) / 69.0
|
||||
`, {target_lat: lat, target_lon: lon, radius}),
|
||||
|
||||
orderBy(`${orderByParam} desc`),
|
||||
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
|
||||
|
||||
orderBy(`${tablePrefix}.${orderByParam} DESC`),
|
||||
after &&
|
||||
where(
|
||||
`profiles.${orderByParam} < (select profiles.${orderByParam} from profiles where id = $(after))`,
|
||||
`${tablePrefix}.${orderByParam} < (
|
||||
SELECT ${tablePrefix}.${orderByParam}
|
||||
FROM profiles
|
||||
LEFT JOIN ${userActivityJoin}
|
||||
WHERE profiles.id = $(after)
|
||||
)`,
|
||||
{after}
|
||||
),
|
||||
|
||||
|
||||
@@ -174,11 +174,63 @@ export type APIHandler<N extends APIPath> = (
|
||||
req: Request
|
||||
) => Promise<APIResponseOptionalContinue<N>>
|
||||
|
||||
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
|
||||
// Not suitable for multi-instance deployments without a shared store, but provides basic protection.
|
||||
// Limits are configurable via env:
|
||||
// API_RATE_LIMIT_PER_MIN_AUTHED
|
||||
// API_RATE_LIMIT_PER_MIN_UNAUTHED
|
||||
// Endpoints can be exempted by adding their name to RATE_LIMIT_EXEMPT (comma-separated)
|
||||
const __rateLimitState: Map<string, { windowStart: number; count: number }> = new Map()
|
||||
|
||||
function getRateLimitConfig() {
|
||||
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 120)
|
||||
const unAuthed = Number(process.env.API_RATE_LIMIT_PER_MIN_UNAUTHED ?? 120)
|
||||
return {authedLimit: authed, unAuthLimit: unAuthed}
|
||||
}
|
||||
|
||||
function rateLimitKey(name: string, req: Request, auth?: AuthedUser) {
|
||||
if (auth) return `uid:${auth.uid}`
|
||||
// fallback to IP for unauthenticated requests
|
||||
return `ip:${req.ip}`
|
||||
}
|
||||
|
||||
function checkRateLimit(name: string, req: Request, res: Response, auth?: AuthedUser) {
|
||||
const {authedLimit, unAuthLimit} = getRateLimitConfig()
|
||||
|
||||
const key = rateLimitKey(name, req, auth)
|
||||
const limit = auth ? authedLimit : unAuthLimit
|
||||
const now = Date.now()
|
||||
const windowMs = 60_000
|
||||
const windowStart = Math.floor(now / windowMs) * windowMs
|
||||
|
||||
let state = __rateLimitState.get(key)
|
||||
if (!state || state.windowStart !== windowStart) {
|
||||
state = {windowStart, count: 0}
|
||||
__rateLimitState.set(key, state)
|
||||
}
|
||||
state.count += 1
|
||||
|
||||
const remaining = Math.max(0, limit - state.count)
|
||||
const reset = Math.ceil((state.windowStart + windowMs - now) / 1000)
|
||||
|
||||
// Set standard-ish rate limit headers
|
||||
res.setHeader('X-RateLimit-Limit', String(limit))
|
||||
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, remaining)))
|
||||
res.setHeader('X-RateLimit-Reset', String(reset))
|
||||
|
||||
// console.log(`Rate limit check for ${key} on ${name}: ${state.count}/${limit} (remaining: ${remaining}, resets in ${reset}s)`)
|
||||
|
||||
if (state.count > limit) {
|
||||
res.setHeader('Retry-After', String(reset))
|
||||
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
|
||||
}
|
||||
}
|
||||
|
||||
export const typedEndpoint = <N extends APIPath>(
|
||||
name: N,
|
||||
handler: APIHandler<N>
|
||||
) => {
|
||||
const {props: propSchema, authed: authRequired, method} = API[name]
|
||||
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let authUser: AuthedUser | undefined = undefined
|
||||
@@ -188,6 +240,15 @@ export const typedEndpoint = <N extends APIPath>(
|
||||
if (authRequired) return next(e)
|
||||
}
|
||||
|
||||
// Apply rate limiting before invoking the handler
|
||||
if (rateLimited) {
|
||||
try {
|
||||
checkRateLimit(String(name), req, res, authUser)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
const props = {
|
||||
...(method === 'GET' ? req.query : req.body),
|
||||
...req.params,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {Row} from "common/supabase/utils";
|
||||
import {DOMAIN} from "common/envs/constants";
|
||||
|
||||
// abusable: people can report the wrong person, that didn't write the comment
|
||||
// but in practice we check it manually and nothing bad happens to them automatically
|
||||
@@ -33,5 +36,38 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
const continuation = async () => {
|
||||
try {
|
||||
const {data: reporter, error} = await tryCatch(
|
||||
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
|
||||
)
|
||||
if (error) {
|
||||
console.error('Failed to get user for report', error)
|
||||
return
|
||||
}
|
||||
const {data: reported, error: userError} = await tryCatch(
|
||||
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId])
|
||||
)
|
||||
if (userError) {
|
||||
console.error('Failed to get reported user for report', userError)
|
||||
return
|
||||
}
|
||||
let message: string = `
|
||||
🚨 **New Report** 🚨
|
||||
**Type:** ${contentType}
|
||||
**Content ID:** ${contentId}
|
||||
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://www.${DOMAIN}/${reporter?.username}))
|
||||
**Reported:** ${reported?.name} ([@${reported?.username}](https://www.${DOMAIN}/${reported?.username}))
|
||||
`
|
||||
await sendDiscordMessage(message, 'reports')
|
||||
} catch (e) {
|
||||
console.error('Failed to send discord reports', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,9 @@ export const sendSearchNotifications = async () => {
|
||||
|
||||
for (const row of searches) {
|
||||
if (typeof row.search_filters !== 'object') continue;
|
||||
const { orderBy, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||
const props = {
|
||||
...row.search_filters,
|
||||
...filters,
|
||||
skipId: row.creator_id,
|
||||
lastModificationWithin: '24 hours',
|
||||
shortBio: true,
|
||||
|
||||
22
backend/api/src/set-last-online-time.ts
Normal file
22
backend/api/src/set-last-online-time.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
if (!auth || !auth.uid) return
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none(`
|
||||
INSERT INTO user_activity (user_id, last_online_time)
|
||||
VALUES ($1, now())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE
|
||||
SET last_online_time = EXCLUDED.last_online_time
|
||||
WHERE user_activity.last_online_time < now() - interval '1 minute';
|
||||
`,
|
||||
[auth.uid]
|
||||
)
|
||||
// console.log('setLastOnline')
|
||||
}
|
||||
@@ -24,8 +24,7 @@ export const updateProfile: APIHandler<'update-profile'> = async (
|
||||
throw new APIError(404, 'Profile not found')
|
||||
}
|
||||
|
||||
!parsedBody.last_online_time &&
|
||||
log('Updating profile', { userId: auth.uid, parsedBody })
|
||||
log('Updating profile', { userId: auth.uid, parsedBody })
|
||||
|
||||
await removePinnedUrlFromPhotoUrls(parsedBody)
|
||||
if (parsedBody.avatar_url) {
|
||||
|
||||
39
backend/api/src/vote.ts
Normal file
39
backend/api/src/vote.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIHandler, APIError } from './helpers/endpoint'
|
||||
|
||||
export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => {
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Map string choice to smallint (-1, 0, 1)
|
||||
const choiceMap: Record<string, number> = {
|
||||
'for': 1,
|
||||
'abstain': 0,
|
||||
'against': -1,
|
||||
}
|
||||
const choiceVal = choiceMap[choice]
|
||||
if (choiceVal === undefined) {
|
||||
throw new APIError(400, 'Invalid choice')
|
||||
}
|
||||
|
||||
// Upsert the vote result to ensure one vote per user per vote
|
||||
// Assuming table vote_results with unique (user_id, vote_id)
|
||||
const query = `
|
||||
insert into vote_results (user_id, vote_id, choice, priority)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (user_id, vote_id)
|
||||
do update set choice = excluded.choice,
|
||||
priority = excluded.priority
|
||||
returning *;
|
||||
`
|
||||
|
||||
try {
|
||||
const result = await pg.one(query, [user.id, voteId, choiceVal, priority])
|
||||
return { data: result }
|
||||
} catch (e) {
|
||||
throw new APIError(500, 'Error recording vote', e as any)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ 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>'
|
||||
const from = 'Compass <compass@compassmeet.com>'
|
||||
|
||||
// export const sendNewMatchEmail = async (
|
||||
// privateUser: PrivateUser,
|
||||
|
||||
@@ -31,14 +31,14 @@ export const sinclairProfile: ProfileRow = {
|
||||
id: 55,
|
||||
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||
created_time: '2023-10-27T00:41:59.851776+00:00',
|
||||
last_online_time: '2024-05-17T02:11:48.83+00:00',
|
||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||
city: 'San Francisco',
|
||||
gender: 'trans-female',
|
||||
pref_gender: ['female', 'trans-female'],
|
||||
pref_age_min: 18,
|
||||
pref_age_max: 21,
|
||||
pref_relation_styles: ['poly', 'open', 'mono'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 3,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
@@ -132,14 +132,14 @@ export const jamesProfile: ProfileRow = {
|
||||
id: 2,
|
||||
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
||||
created_time: '2023-10-21T21:18:26.691211+00:00',
|
||||
last_online_time: '2024-07-06T17:29:16.833+00:00',
|
||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||
city: 'San Francisco',
|
||||
gender: 'male',
|
||||
pref_gender: ['female'],
|
||||
pref_age_min: 22,
|
||||
pref_age_max: 32,
|
||||
pref_relation_styles: ['mono'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 4,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
type CreateEmailOptions,
|
||||
} from 'resend'
|
||||
import { log } from 'shared/utils'
|
||||
import {sleep} from "common/util/time";
|
||||
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
const limit = pLimit(1) // 1 concurrent per second
|
||||
|
||||
/*
|
||||
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
|
||||
@@ -19,13 +17,10 @@ export const sendEmail = async (
|
||||
const resend = getResend()
|
||||
console.debug(resend, payload, options)
|
||||
|
||||
async function sendEmailThrottle(data: any, options: any) {
|
||||
if (!resend) return { data: null, error: 'No Resend client' }
|
||||
return limit(() => resend.emails.send(data, options))
|
||||
}
|
||||
if (!resend) return null
|
||||
|
||||
const { data, error } = await sendEmailThrottle(
|
||||
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
|
||||
const { data, error } = await resend.emails.send(
|
||||
{ replyTo: 'Compass <hello@compassmeet.com>', ...payload },
|
||||
options
|
||||
)
|
||||
console.debug('resend.emails.send', data, error)
|
||||
@@ -39,6 +34,9 @@ export const sendEmail = async (
|
||||
}
|
||||
|
||||
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
|
||||
|
||||
await sleep(1000) // to avoid rate limits (2 / second in resend free plan)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
|
||||
import {discordLink, githubRepo, patreonLink, paypalLink} from "common/constants";
|
||||
import {DOMAIN} from "common/envs/constants";
|
||||
|
||||
interface Props {
|
||||
@@ -15,40 +14,40 @@ export const Footer = ({
|
||||
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
|
||||
<Row>
|
||||
<Column align="center">
|
||||
<Link href={githubRepo} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/github`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/github-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="GitHub"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={discordLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/discord`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/discord-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Discord"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={patreonLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/patreon`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/patreon-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Patreon"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={paypalLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/paypal`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/paypal-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="PayPal"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
|
||||
14
backend/supabase/contact.sql
Normal file
14
backend/supabase/contact.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
create table if not exists
|
||||
contact (
|
||||
id text default uuid_generate_v4 () not null,
|
||||
created_time timestamp with time zone default now(),
|
||||
user_id text,
|
||||
content jsonb
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
alter table contact
|
||||
add constraint contact_user_id_fkey foreign key (user_id) references users (id);
|
||||
|
||||
-- Row Level Security
|
||||
alter table contact enable row level security;
|
||||
@@ -28,7 +28,6 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
is_smoker BOOLEAN,
|
||||
is_vegetarian_or_vegan BOOLEAN,
|
||||
last_online_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
|
||||
@@ -41,6 +40,7 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
pref_age_min INTEGER NULL,
|
||||
pref_gender TEXT[] NOT NULL,
|
||||
pref_relation_styles TEXT[] NOT NULL,
|
||||
pref_romantic_styles TEXT[],
|
||||
referred_by_username TEXT,
|
||||
region_code TEXT,
|
||||
religious_belief_strength INTEGER,
|
||||
@@ -59,10 +59,8 @@ ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON profiles;
|
||||
|
||||
CREATE POLICY "public read" ON profiles
|
||||
FOR SELECT
|
||||
USING (true);
|
||||
FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "self update" ON profiles;
|
||||
|
||||
@@ -83,23 +81,26 @@ CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
|
||||
ON profiles (bio_length);
|
||||
|
||||
-- Fastest general-purpose index
|
||||
DROP INDEX IF EXISTS profiles_lat_lon_idx;
|
||||
CREATE INDEX profiles_lat_lon_idx ON profiles (city_latitude, city_longitude);
|
||||
|
||||
-- Optional additional index for large tables / clustered inserts
|
||||
DROP INDEX IF EXISTS profiles_lat_lon_brin_idx;
|
||||
CREATE INDEX profiles_lat_lon_brin_idx ON profiles USING BRIN (city_latitude, city_longitude) WITH (pages_per_range = 32);
|
||||
|
||||
|
||||
|
||||
-- Functions and Triggers
|
||||
CREATE
|
||||
OR REPLACE FUNCTION update_last_modification_time()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.last_online_time IS DISTINCT FROM OLD.last_online_time AND row(NEW.*) = row(OLD.*) THEN
|
||||
-- Only last_online_time changed, do nothing
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Some other column changed
|
||||
NEW.last_modification_time = now();
|
||||
RETURN NEW;
|
||||
NEW.last_modification_time = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
CREATE TRIGGER trigger_update_last_mod_time
|
||||
BEFORE UPDATE
|
||||
@@ -141,5 +142,3 @@ $$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER trg_update_bio_text
|
||||
BEFORE INSERT OR UPDATE OF bio ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_bio_text();
|
||||
|
||||
|
||||
|
||||
16
backend/supabase/user_activity.sql
Normal file
16
backend/supabase/user_activity.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE user_activity
|
||||
(
|
||||
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||
last_online_time TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE user_activity ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "public read" ON user_activity;
|
||||
CREATE POLICY "public read" ON user_activity
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_user_activity_last_online_time
|
||||
ON user_activity (last_online_time DESC);
|
||||
94
backend/supabase/vote_results.sql
Normal file
94
backend/supabase/vote_results.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
CREATE TABLE IF NOT EXISTS vote_results (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
vote_id BIGINT NOT NULL,
|
||||
choice smallint NOT NULL CHECK (choice IN (-1, 0, 1)),
|
||||
priority smallint NOT NULL CHECK (priority IN (0, 1, 2, 3)),
|
||||
UNIQUE (user_id, vote_id) -- ensures one vote per user
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
alter table vote_results
|
||||
add constraint vote_results_user_id_fkey foreign key (user_id) references users (id);
|
||||
|
||||
alter table vote_results
|
||||
add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes (id);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON vote_results;
|
||||
CREATE POLICY "public read" ON vote_results
|
||||
FOR ALL USING (true);
|
||||
|
||||
-- Indexes
|
||||
DROP INDEX IF EXISTS user_id_idx;
|
||||
CREATE INDEX user_id_idx ON vote_results (user_id);
|
||||
|
||||
DROP INDEX IF EXISTS vote_id_idx;
|
||||
CREATE INDEX vote_id_idx ON vote_results (vote_id);
|
||||
|
||||
DROP INDEX IF EXISTS idx_vote_results_vote_choice;
|
||||
CREATE INDEX idx_vote_results_vote_choice ON vote_results (vote_id, choice);
|
||||
|
||||
DROP INDEX IF EXISTS idx_vote_results_vote_choice_priority;
|
||||
CREATE INDEX idx_vote_results_vote_choice_priority ON vote_results (vote_id, choice, priority);
|
||||
|
||||
DROP INDEX IF EXISTS idx_votes_created_time;
|
||||
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
|
||||
|
||||
|
||||
drop function if exists get_votes_with_results;
|
||||
create or replace function get_votes_with_results(order_by text default 'recent')
|
||||
returns table (
|
||||
id BIGINT,
|
||||
title text,
|
||||
description jsonb,
|
||||
created_time timestamptz,
|
||||
creator_id TEXT,
|
||||
is_anonymous boolean,
|
||||
votes_for int,
|
||||
votes_against int,
|
||||
votes_abstain int,
|
||||
priority int
|
||||
)
|
||||
as $$
|
||||
with results as (
|
||||
SELECT
|
||||
v.id,
|
||||
v.title,
|
||||
v.description,
|
||||
v.created_time,
|
||||
v.creator_id,
|
||||
v.is_anonymous,
|
||||
COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 0) AS votes_for,
|
||||
COALESCE(SUM(CASE WHEN r.choice = -1 THEN 1 ELSE 0 END), 0) AS votes_against,
|
||||
COALESCE(SUM(CASE WHEN r.choice = 0 THEN 1 ELSE 0 END), 0) AS votes_abstain,
|
||||
COALESCE(SUM(r.priority), 0)::float / GREATEST(COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 1), 1) * 100 / 3 AS priority
|
||||
FROM votes v
|
||||
LEFT JOIN vote_results r ON v.id = r.vote_id
|
||||
GROUP BY v.id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
created_time,
|
||||
creator_id,
|
||||
is_anonymous,
|
||||
votes_for,
|
||||
votes_against,
|
||||
votes_abstain,
|
||||
priority
|
||||
FROM results
|
||||
ORDER BY
|
||||
CASE WHEN order_by = 'recent' THEN created_time END DESC,
|
||||
CASE WHEN order_by = 'mostVoted' THEN (votes_for + votes_against + votes_abstain) END DESC,
|
||||
CASE WHEN order_by = 'mostVoted' THEN created_time END DESC,
|
||||
CASE WHEN order_by = 'priority' THEN priority END DESC,
|
||||
CASE WHEN order_by = 'priority' THEN created_time END DESC;
|
||||
$$ language sql stable;
|
||||
|
||||
|
||||
27
backend/supabase/votes.sql
Normal file
27
backend/supabase/votes.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
creator_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
is_anonymous BOOLEAN NOT NULL,
|
||||
description JSONB
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
alter table votes
|
||||
add constraint votes_creator_id_fkey foreign key (creator_id) references users (id);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON votes;
|
||||
CREATE POLICY "public read" ON votes
|
||||
FOR ALL USING (true);
|
||||
|
||||
-- Indexes
|
||||
DROP INDEX IF EXISTS creator_id_idx;
|
||||
CREATE INDEX creator_id_idx ON votes (creator_id);
|
||||
|
||||
DROP INDEX IF EXISTS idx_votes_created_time;
|
||||
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
|
||||
@@ -4,19 +4,19 @@ import {
|
||||
baseProfilesSchema,
|
||||
arraybeSchema,
|
||||
} from 'common/api/zod-types'
|
||||
import { PrivateChatMessage } from 'common/chat-message'
|
||||
import { CompatibilityScore } from 'common/love/compatibility-score'
|
||||
import { MAX_COMPATIBILITY_QUESTION_LENGTH } from 'common/love/constants'
|
||||
import { Profile, ProfileRow } from 'common/love/profile'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { PrivateUser, User } from 'common/user'
|
||||
import { z } from 'zod'
|
||||
import { LikeData, ShipData } from './love-types'
|
||||
import { DisplayUser, FullUser } from './user-types'
|
||||
import { PrivateMessageChannel } from 'common/supabase/private-messages'
|
||||
import { Notification } from 'common/notifications'
|
||||
import { arrify } from 'common/util/array'
|
||||
import { notification_preference } from 'common/user-notification-preferences'
|
||||
import {PrivateChatMessage} from 'common/chat-message'
|
||||
import {CompatibilityScore} from 'common/love/compatibility-score'
|
||||
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/love/constants'
|
||||
import {Profile, ProfileRow} from 'common/love/profile'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {PrivateUser, User} from 'common/user'
|
||||
import {z} from 'zod'
|
||||
import {LikeData, ShipData} from './love-types'
|
||||
import {DisplayUser, FullUser} from './user-types'
|
||||
import {PrivateMessageChannel} from 'common/supabase/private-messages'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {arrify} from 'common/util/array'
|
||||
import {notification_preference} from 'common/user-notification-preferences'
|
||||
|
||||
// mqp: very unscientific, just balancing our willingness to accept load
|
||||
// with user willingness to put up with stale data
|
||||
@@ -28,6 +28,8 @@ type APIGenericSchema = {
|
||||
method: 'GET' | 'POST' | 'PUT'
|
||||
// whether the endpoint requires authentication
|
||||
authed: boolean
|
||||
// whether the endpoint requires authentication
|
||||
rateLimited?: boolean
|
||||
// zod schema for the request body (or for params for GET requests)
|
||||
props: z.ZodType
|
||||
// note this has to be JSON serializable
|
||||
@@ -42,33 +44,39 @@ export const API = (_apiTypeCheck = {
|
||||
health: {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as { message: 'Server is working.'; uid?: string },
|
||||
},
|
||||
'get-supabase-token': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as { jwt: string },
|
||||
},
|
||||
'mark-all-notifs-read': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
},
|
||||
'user/by-id/:id/block': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
props: z.object({ id: z.string() }).strict(),
|
||||
rateLimited: false,
|
||||
props: z.object({id: z.string()}).strict(),
|
||||
},
|
||||
'user/by-id/:id/unblock': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
props: z.object({ id: z.string() }).strict(),
|
||||
rateLimited: false,
|
||||
props: z.object({id: z.string()}).strict(),
|
||||
},
|
||||
'ban-user': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
@@ -80,6 +88,7 @@ export const API = (_apiTypeCheck = {
|
||||
// TODO rest
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as { user: User; privateUser: PrivateUser },
|
||||
props: z
|
||||
.object({
|
||||
@@ -91,12 +100,14 @@ export const API = (_apiTypeCheck = {
|
||||
'create-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as Row<'profiles'>,
|
||||
props: baseProfilesSchema,
|
||||
},
|
||||
report: {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
contentOwnerId: z.string(),
|
||||
@@ -112,6 +123,7 @@ export const API = (_apiTypeCheck = {
|
||||
me: {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
props: z.object({}),
|
||||
returns: {} as FullUser,
|
||||
@@ -119,6 +131,7 @@ export const API = (_apiTypeCheck = {
|
||||
'me/update': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
name: z.string().trim().min(1).optional(),
|
||||
username: z.string().trim().min(1).optional(),
|
||||
@@ -146,12 +159,14 @@ export const API = (_apiTypeCheck = {
|
||||
'update-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: combinedLoveUsersSchema.partial(),
|
||||
returns: {} as ProfileRow,
|
||||
},
|
||||
'update-notif-settings': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
type: z.string() as z.ZodType<notification_preference>,
|
||||
medium: z.enum(['email', 'browser', 'mobile']),
|
||||
@@ -161,6 +176,7 @@ export const API = (_apiTypeCheck = {
|
||||
'me/delete': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
username: z.string(), // just so you're sure
|
||||
}),
|
||||
@@ -168,54 +184,61 @@ export const API = (_apiTypeCheck = {
|
||||
'me/private': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as PrivateUser,
|
||||
},
|
||||
'user/:username': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: {} as FullUser,
|
||||
props: z.object({ username: z.string() }).strict(),
|
||||
props: z.object({username: z.string()}).strict(),
|
||||
},
|
||||
'user/:username/lite': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: {} as DisplayUser,
|
||||
props: z.object({ username: z.string() }).strict(),
|
||||
props: z.object({username: z.string()}).strict(),
|
||||
},
|
||||
'user/by-id/:id': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: {} as FullUser,
|
||||
props: z.object({ id: z.string() }).strict(),
|
||||
props: z.object({id: z.string()}).strict(),
|
||||
},
|
||||
'user/by-id/:id/lite': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: {} as DisplayUser,
|
||||
props: z.object({ id: z.string() }).strict(),
|
||||
props: z.object({id: z.string()}).strict(),
|
||||
},
|
||||
'search-users': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: [] as FullUser[],
|
||||
props: z
|
||||
.object({
|
||||
term: z.string(),
|
||||
limit: z.coerce.number().gte(0).lte(1000).default(500),
|
||||
limit: z.coerce.number().gte(0).lte(20).default(500),
|
||||
page: z.coerce.number().gte(0).default(0),
|
||||
})
|
||||
.strict(),
|
||||
},
|
||||
'compatible-profiles': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
props: z.object({ userId: z.string() }),
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({userId: z.string()}),
|
||||
returns: {} as {
|
||||
profile: Profile
|
||||
compatibleProfiles: Profile[]
|
||||
@@ -227,7 +250,8 @@ export const API = (_apiTypeCheck = {
|
||||
'remove-pinned-photo': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
returns: { success: true },
|
||||
rateLimited: true,
|
||||
returns: {success: true},
|
||||
props: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
@@ -236,7 +260,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-compatibility-questions': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
@@ -249,6 +274,7 @@ export const API = (_apiTypeCheck = {
|
||||
'like-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId: z.string(),
|
||||
remove: z.boolean().optional(),
|
||||
@@ -260,6 +286,7 @@ export const API = (_apiTypeCheck = {
|
||||
'ship-profiles': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId1: z.string(),
|
||||
targetUserId2: z.string(),
|
||||
@@ -271,7 +298,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-likes-and-ships': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
@@ -287,6 +315,7 @@ export const API = (_apiTypeCheck = {
|
||||
'has-free-like': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({}).strict(),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
@@ -296,6 +325,7 @@ export const API = (_apiTypeCheck = {
|
||||
'star-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId: z.string(),
|
||||
remove: z.boolean().optional(),
|
||||
@@ -306,10 +336,11 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-profiles': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
limit: z.coerce.number().optional().default(20),
|
||||
limit: z.coerce.number().gt(0).lte(20).optional().default(20),
|
||||
after: z.string().optional(),
|
||||
// Search and filter parameters
|
||||
name: z.string().optional(),
|
||||
@@ -318,11 +349,15 @@ export const API = (_apiTypeCheck = {
|
||||
pref_age_min: z.coerce.number().optional(),
|
||||
pref_age_max: z.coerce.number().optional(),
|
||||
pref_relation_styles: arraybeSchema.optional(),
|
||||
pref_romantic_styles: arraybeSchema.optional(),
|
||||
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(),
|
||||
lat: z.coerce.number().optional(),
|
||||
lon: z.coerce.number().optional(),
|
||||
radius: z.coerce.number().optional(),
|
||||
compatibleWithUserId: z.string().optional(),
|
||||
orderBy: z
|
||||
.enum(['last_online_time', 'created_time', 'compatibility_score'])
|
||||
@@ -337,8 +372,9 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-profile-answers': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
props: z.object({ userId: z.string() }).strict(),
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({userId: z.string()}).strict(),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
answers: Row<'love_compatibility_answers'>[]
|
||||
@@ -347,6 +383,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-comment': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
userId: z.string(),
|
||||
content: contentSchema,
|
||||
@@ -357,6 +394,7 @@ export const API = (_apiTypeCheck = {
|
||||
'hide-comment': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
commentId: z.string(),
|
||||
hide: z.boolean(),
|
||||
@@ -366,6 +404,7 @@ export const API = (_apiTypeCheck = {
|
||||
'get-channel-memberships': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
channelId: z.coerce.number().optional(),
|
||||
createdTime: z.string().optional(),
|
||||
@@ -380,6 +419,7 @@ export const API = (_apiTypeCheck = {
|
||||
'get-channel-messages': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
channelId: z.coerce.number(),
|
||||
limit: z.coerce.number(),
|
||||
@@ -390,6 +430,7 @@ export const API = (_apiTypeCheck = {
|
||||
'get-channel-seen-time': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
channelIds: z
|
||||
.array(z.coerce.number())
|
||||
@@ -401,13 +442,21 @@ export const API = (_apiTypeCheck = {
|
||||
'set-channel-seen-time': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
channelId: z.coerce.number(),
|
||||
}),
|
||||
},
|
||||
'set-last-online-time': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
},
|
||||
'get-notifications': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
returns: [] as Notification[],
|
||||
props: z
|
||||
.object({
|
||||
@@ -419,6 +468,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-private-user-message': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
content: contentSchema,
|
||||
@@ -428,6 +478,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
userIds: z.array(z.string()),
|
||||
@@ -436,6 +487,7 @@ export const API = (_apiTypeCheck = {
|
||||
'update-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
channelId: z.number(),
|
||||
@@ -445,6 +497,7 @@ export const API = (_apiTypeCheck = {
|
||||
'leave-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
channelId: z.number(),
|
||||
@@ -453,15 +506,39 @@ export const API = (_apiTypeCheck = {
|
||||
'create-compatibility-question': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
|
||||
options: z.record(z.string(), z.number()),
|
||||
}),
|
||||
},
|
||||
'create-vote': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
title: z.string().min(1),
|
||||
isAnonymous: z.boolean(),
|
||||
description: contentSchema,
|
||||
}),
|
||||
},
|
||||
'vote': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
voteId: z.number(),
|
||||
priority: z.number(),
|
||||
choice: z.enum(['for', 'abstain', 'against']),
|
||||
}),
|
||||
},
|
||||
'search-location': {
|
||||
method: 'POST',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
term: z.string(),
|
||||
@@ -470,13 +547,31 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'search-near-city': {
|
||||
method: 'POST',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
cityId: z.string(),
|
||||
radius: z.number().min(1).max(500),
|
||||
}),
|
||||
},
|
||||
'contact': {
|
||||
method: 'POST',
|
||||
authed: false,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
content: contentSchema,
|
||||
userId: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'get-messages-count': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as { count: number },
|
||||
},
|
||||
} as const)
|
||||
|
||||
export type APIPath = keyof typeof API
|
||||
@@ -488,8 +583,8 @@ export type ValidatedAPIParams<N extends APIPath> = z.output<
|
||||
>
|
||||
|
||||
export type APIResponse<N extends APIPath> = APISchema<N> extends {
|
||||
returns: Record<string, any>
|
||||
}
|
||||
returns: Record<string, any>
|
||||
}
|
||||
? APISchema<N>['returns']
|
||||
: void
|
||||
|
||||
|
||||
@@ -49,13 +49,7 @@ export const baseProfilesSchema = z.object({
|
||||
pref_gender: genderTypes,
|
||||
pref_age_min: z.number().min(18).max(100).optional(),
|
||||
pref_age_max: z.number().min(18).max(100).optional(),
|
||||
pref_relation_styles: z.array(
|
||||
z.union([
|
||||
z.literal('collaboration'),
|
||||
z.literal('friendship'),
|
||||
z.literal('relationship'),
|
||||
])
|
||||
),
|
||||
pref_relation_styles: z.array(z.string()),
|
||||
wants_kids_strength: z.number(),
|
||||
looking_for_matches: z.boolean(),
|
||||
photo_urls: z.array(z.string()),
|
||||
@@ -84,7 +78,6 @@ const optionalProfilesSchema = z.object({
|
||||
height_in_inches: z.number().optional(),
|
||||
has_pets: z.boolean().optional(),
|
||||
education_level: z.string().optional(),
|
||||
last_online_time: z.string().optional(),
|
||||
is_smoker: z.boolean().optional(),
|
||||
drinks_per_month: z.number().min(0).optional(),
|
||||
is_vegetarian_or_vegan: z.boolean().optional(),
|
||||
@@ -98,6 +91,7 @@ const optionalProfilesSchema = z.object({
|
||||
bio: contentSchema.optional().nullable(),
|
||||
twitter: z.string().optional(),
|
||||
avatar_url: z.string().optional(),
|
||||
pref_romantic_styles: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const combinedLoveUsersSchema =
|
||||
|
||||
@@ -20,3 +20,4 @@ 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;
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@ export const sendDiscordMessage = async (content: string, channel: string) => {
|
||||
let webhookUrl = {
|
||||
members: process.env.DISCORD_WEBHOOK_MEMBERS,
|
||||
general: process.env.DISCORD_WEBHOOK_GENERAL,
|
||||
health: process.env.DISCORD_WEBHOOK_HEALTH,
|
||||
reports: process.env.DISCORD_WEBHOOK_REPORTS,
|
||||
contact: process.env.DISCORD_WEBHOOK_CONTACT,
|
||||
}[channel]
|
||||
|
||||
if (IS_DEV) webhookUrl = process.env.DISCORD_WEBHOOK_DEV
|
||||
|
||||
// console.log(`Discord webhook URL: ${webhookUrl}`, channel, content)
|
||||
|
||||
if (!webhookUrl) return
|
||||
|
||||
const response = await fetch(webhookUrl!, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {DEV_CONFIG} from './dev'
|
||||
import {PROD_CONFIG} from './prod'
|
||||
import {isProd} from "common/envs/is-prod";
|
||||
|
||||
export const MAX_DESCRIPTION_LENGTH = 16000
|
||||
export const MAX_DESCRIPTION_LENGTH = 100000
|
||||
export const MAX_ANSWER_LENGTH = 240
|
||||
|
||||
export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG
|
||||
|
||||
@@ -2,9 +2,18 @@ import {Profile, ProfileRow} from "common/love/profile";
|
||||
import {cloneDeep} from "lodash";
|
||||
import {filterDefined} from "common/util/array";
|
||||
|
||||
// export type TargetArea = {
|
||||
// lat: number
|
||||
// lon: number
|
||||
// radius: number
|
||||
// }
|
||||
|
||||
export type FilterFields = {
|
||||
orderBy: 'last_online_time' | 'created_time' | 'compatibility_score'
|
||||
geodbCityIds: string[] | null
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
radius: number | null
|
||||
genders: string[]
|
||||
name: string | undefined
|
||||
shortBio: boolean | undefined
|
||||
@@ -18,6 +27,7 @@ export type FilterFields = {
|
||||
| 'pref_age_min'
|
||||
| 'pref_age_max'
|
||||
>
|
||||
|
||||
export const orderProfiles = (
|
||||
profiles: Profile[],
|
||||
starredUserIds: string[] | undefined
|
||||
@@ -39,6 +49,9 @@ export const orderProfiles = (
|
||||
}
|
||||
export const initialFilters: Partial<FilterFields> = {
|
||||
geodbCityIds: undefined,
|
||||
lat: undefined,
|
||||
lon: undefined,
|
||||
radius: undefined,
|
||||
name: undefined,
|
||||
genders: undefined,
|
||||
pref_age_max: undefined,
|
||||
@@ -51,4 +64,8 @@ export const initialFilters: Partial<FilterFields> = {
|
||||
shortBio: undefined,
|
||||
orderBy: 'created_time',
|
||||
}
|
||||
export type OriginLocation = { id: string; name: string }
|
||||
|
||||
|
||||
export const FilterKeys = Object.keys(initialFilters) as (keyof FilterFields)[]
|
||||
|
||||
export type OriginLocation = { id: string; name: string, lat: number, lon: number }
|
||||
|
||||
89
common/src/md.ts
Normal file
89
common/src/md.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
export function jsonToMarkdown(node: JSONContent): string {
|
||||
if (!node) return ''
|
||||
|
||||
// Text node
|
||||
if (node.type === 'text') {
|
||||
let text = node.text || ''
|
||||
|
||||
if (node.marks) {
|
||||
for (const mark of node.marks) {
|
||||
switch (mark.type) {
|
||||
case 'bold':
|
||||
text = `**${text}**`
|
||||
break
|
||||
case 'italic':
|
||||
text = `*${text}*`
|
||||
break
|
||||
case 'strike':
|
||||
text = `~~${text}~~`
|
||||
break
|
||||
case 'code':
|
||||
text = `\`${text}\``
|
||||
break
|
||||
case 'link':
|
||||
text = `[${text}](${mark.attrs?.href ?? ''})`
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// Non-text nodes: recursively process children
|
||||
const content = (node.content || []).map(jsonToMarkdown).join('')
|
||||
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
return `${content}\n`
|
||||
case 'heading': {
|
||||
const level = node.attrs?.level || 1
|
||||
return `${'#'.repeat(level)} ${content}\n`
|
||||
}
|
||||
case 'bulletList':
|
||||
return `${content}`
|
||||
case 'orderedList':
|
||||
return `${content}`
|
||||
case 'listItem':
|
||||
return `- ${content}`
|
||||
case 'blockquote':
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => (line ? `> ${line}` : ''))
|
||||
.join('\n') + '\n\n'
|
||||
case 'codeBlock':
|
||||
return `\`\`\`\n${content}\n\`\`\`\n\n`
|
||||
case 'horizontalRule':
|
||||
return `---\n\n`
|
||||
case 'hardBreak':
|
||||
return ` \n`
|
||||
default:
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// function extractTextFromJsonb(bio: JSONContent): 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 ''
|
||||
// }
|
||||
// }
|
||||
@@ -25,6 +25,18 @@ export type locationType = {
|
||||
radius: number
|
||||
}
|
||||
|
||||
const skippedKeys = [
|
||||
'pref_age_min',
|
||||
'pref_age_max',
|
||||
'geodbCityIds',
|
||||
'orderBy',
|
||||
'shortBio',
|
||||
'targetArea',
|
||||
'lat',
|
||||
'lon',
|
||||
'radius',
|
||||
]
|
||||
|
||||
|
||||
export function formatFilters(filters: Partial<FilterFields>, location: locationType | null): String[] | null {
|
||||
const entries: String[] = []
|
||||
@@ -53,7 +65,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' || typedKey == 'shortBio') return
|
||||
if (skippedKeys.includes(typedKey)) return
|
||||
if (Array.isArray(value) && value.length === 0) return
|
||||
if (initialFilters[typedKey] === value) return
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export const secrets = (
|
||||
'NEXT_PUBLIC_FIREBASE_API_KEY',
|
||||
'DISCORD_WEBHOOK_MEMBERS',
|
||||
'DISCORD_WEBHOOK_GENERAL',
|
||||
'DISCORD_WEBHOOK_HEALTH',
|
||||
'DISCORD_WEBHOOK_REPORTS',
|
||||
'DISCORD_WEBHOOK_CONTACT',
|
||||
// Some typescript voodoo to keep the string literal types while being not readonly.
|
||||
] as const
|
||||
).concat()
|
||||
|
||||
@@ -90,7 +90,7 @@ export const getSocialUrl = (site: Site, handle: string) =>
|
||||
const urler: { [key in Site]: (handle: string) => string } = {
|
||||
site: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
okcupid: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
x: (s) => `https://x.com/${s}`,
|
||||
x: (s) => s.startsWith('http') ? s : `https://x.com/${s}`,
|
||||
discord: (s) =>
|
||||
(s.length === 17 || s.length === 18) && !isNaN(parseInt(s, 10))
|
||||
? `https://discord.com/users/${s}` // discord user id
|
||||
@@ -98,14 +98,14 @@ const urler: { [key in Site]: (handle: string) => string } = {
|
||||
bluesky: (s) => `https://bsky.app/profile/${s}`,
|
||||
mastodon: (s) =>
|
||||
s.includes('@') ? `https://${s.split('@')[1]}/@${s.split('@')[0]}` : s,
|
||||
substack: (s) => `https://${s}.substack.com`,
|
||||
instagram: (s) => `https://instagram.com/${s}`,
|
||||
github: (s) => `https://github.com/${s}`,
|
||||
linkedin: (s) => `https://linkedin.com/in/${s}`,
|
||||
facebook: (s) => `https://facebook.com/${s}`,
|
||||
spotify: (s) => `https://open.spotify.com/user/${s}`,
|
||||
paypal: (s) => `https://paypal.com/paypalme/${s}`,
|
||||
patreon: (s) => `https://patreon.com/${s}`,
|
||||
substack: (s) => s.startsWith('http') ? s : `https://${s}.substack.com`,
|
||||
instagram: (s) => s.startsWith('http') ? s : `https://instagram.com/${s}`,
|
||||
github: (s) => s.startsWith('http') ? s : `https://github.com/${s}`,
|
||||
linkedin: (s) => s.startsWith('http') ? s : `https://linkedin.com/in/${s}`,
|
||||
facebook: (s) => s.startsWith('http') ? s : `https://facebook.com/${s}`,
|
||||
spotify: (s) => s.startsWith('http') ? s : `https://open.spotify.com/user/${s}`,
|
||||
paypal: (s) => s.startsWith('http') ? s : `https://paypal.com/paypalme/${s}`,
|
||||
patreon: (s) => s.startsWith('http') ? s : `https://patreon.com/${s}`,
|
||||
calendly: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
datingdoc: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
friendshipdoc: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
|
||||
@@ -44,6 +44,35 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
contact: {
|
||||
Row: {
|
||||
content: Json | null
|
||||
created_time: string | null
|
||||
id: string
|
||||
user_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
content?: Json | null
|
||||
created_time?: string | null
|
||||
id?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
content?: Json | null
|
||||
created_time?: string | null
|
||||
id?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'contact_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
love_answers: {
|
||||
Row: {
|
||||
created_time: string
|
||||
@@ -427,7 +456,6 @@ export type Database = {
|
||||
is_smoker: boolean | null
|
||||
is_vegetarian_or_vegan: boolean | null
|
||||
last_modification_time: string
|
||||
last_online_time: string
|
||||
looking_for_matches: boolean
|
||||
messaging_status: string
|
||||
occupation: string | null
|
||||
@@ -439,6 +467,7 @@ export type Database = {
|
||||
pref_age_min: number | null
|
||||
pref_gender: string[]
|
||||
pref_relation_styles: string[]
|
||||
pref_romantic_styles: string[] | null
|
||||
referred_by_username: string | null
|
||||
region_code: string | null
|
||||
religious_belief_strength: number | null
|
||||
@@ -475,7 +504,6 @@ export type Database = {
|
||||
is_smoker?: boolean | null
|
||||
is_vegetarian_or_vegan?: boolean | null
|
||||
last_modification_time?: string
|
||||
last_online_time?: string
|
||||
looking_for_matches?: boolean
|
||||
messaging_status?: string
|
||||
occupation?: string | null
|
||||
@@ -487,6 +515,7 @@ export type Database = {
|
||||
pref_age_min?: number | null
|
||||
pref_gender: string[]
|
||||
pref_relation_styles: string[]
|
||||
pref_romantic_styles?: string[] | null
|
||||
referred_by_username?: string | null
|
||||
region_code?: string | null
|
||||
religious_belief_strength?: number | null
|
||||
@@ -523,7 +552,6 @@ export type Database = {
|
||||
is_smoker?: boolean | null
|
||||
is_vegetarian_or_vegan?: boolean | null
|
||||
last_modification_time?: string
|
||||
last_online_time?: string
|
||||
looking_for_matches?: boolean
|
||||
messaging_status?: string
|
||||
occupation?: string | null
|
||||
@@ -535,6 +563,7 @@ export type Database = {
|
||||
pref_age_min?: number | null
|
||||
pref_gender?: string[]
|
||||
pref_relation_styles?: string[]
|
||||
pref_romantic_styles?: string[] | null
|
||||
referred_by_username?: string | null
|
||||
region_code?: string | null
|
||||
religious_belief_strength?: number | null
|
||||
@@ -599,6 +628,29 @@ export type Database = {
|
||||
}
|
||||
]
|
||||
}
|
||||
user_activity: {
|
||||
Row: {
|
||||
last_online_time: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
last_online_time: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
last_online_time?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'user_activity_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: true
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
user_events: {
|
||||
Row: {
|
||||
ad_id: string | null
|
||||
@@ -677,6 +729,83 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
vote_results: {
|
||||
Row: {
|
||||
choice: number
|
||||
created_time: string
|
||||
id: number
|
||||
priority: number
|
||||
user_id: string
|
||||
vote_id: number
|
||||
}
|
||||
Insert: {
|
||||
choice: number
|
||||
created_time?: string
|
||||
id?: never
|
||||
priority: number
|
||||
user_id: string
|
||||
vote_id: number
|
||||
}
|
||||
Update: {
|
||||
choice?: number
|
||||
created_time?: string
|
||||
id?: never
|
||||
priority?: number
|
||||
user_id?: string
|
||||
vote_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'vote_results_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'vote_results_vote_id_fkey'
|
||||
columns: ['vote_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'votes'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
votes: {
|
||||
Row: {
|
||||
created_time: string
|
||||
creator_id: string
|
||||
description: Json | null
|
||||
id: number
|
||||
is_anonymous: boolean | null
|
||||
title: string
|
||||
}
|
||||
Insert: {
|
||||
created_time?: string
|
||||
creator_id: string
|
||||
description?: Json | null
|
||||
id?: never
|
||||
is_anonymous?: boolean | null
|
||||
title: string
|
||||
}
|
||||
Update: {
|
||||
created_time?: string
|
||||
creator_id?: string
|
||||
description?: Json | null
|
||||
id?: never
|
||||
is_anonymous?: boolean | null
|
||||
title?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'votes_creator_id_fkey'
|
||||
columns: ['creator_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
@@ -710,6 +839,21 @@ export type Database = {
|
||||
Args: { p_question_id: number }
|
||||
Returns: Record<string, unknown>[]
|
||||
}
|
||||
get_votes_with_results: {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: {
|
||||
created_time: string
|
||||
creator_id: string
|
||||
description: Json
|
||||
id: number
|
||||
is_anonymous: boolean
|
||||
priority: number
|
||||
title: string
|
||||
votes_abstain: number
|
||||
votes_against: number
|
||||
votes_for: number
|
||||
}[]
|
||||
}
|
||||
gtrgm_compress: {
|
||||
Args: { '': unknown }
|
||||
Returns: unknown
|
||||
@@ -763,7 +907,7 @@ export type Database = {
|
||||
Returns: Json
|
||||
}
|
||||
ts_to_millis: {
|
||||
Args: { ts: string }
|
||||
Args: { ts: string } | { ts: string }
|
||||
Returns: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export type PrivateUser = {
|
||||
blockedByUserIds: string[]
|
||||
}
|
||||
|
||||
export type UserActivity = {
|
||||
user_id: string // same as User.id
|
||||
last_online_time: string
|
||||
}
|
||||
|
||||
export type UserAndPrivateUser = { user: User; privateUser: PrivateUser }
|
||||
|
||||
export function getCurrentUtcTime(): Date {
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function baseApiCall(props: {
|
||||
body:
|
||||
params == null || method === 'GET' ? undefined : JSON.stringify(params),
|
||||
})
|
||||
// console.debug(req)
|
||||
// console.log('Request', req)
|
||||
return fetch(req).then(async (resp) => {
|
||||
const json = (await resp.json()) as { [k: string]: any }
|
||||
if (!resp.ok) {
|
||||
|
||||
7
common/src/votes/constants.ts
Normal file
7
common/src/votes/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const ORDER_BY = ['recent', 'mostVoted', 'priority'] as const
|
||||
export type OrderBy = typeof ORDER_BY[number]
|
||||
export const Constants: Record<OrderBy, string> = {
|
||||
recent: 'Most recent',
|
||||
mostVoted: 'Most voted',
|
||||
priority: 'Highest Priority',
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "compass",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"common",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from 'web/components/buttons/button'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { MODAL_CLASS, Modal } from 'web/components/layout/modal'
|
||||
import { AnswerCompatibilityQuestionContent } from './answer-compatibility-question-content'
|
||||
import router from "next/router";
|
||||
|
||||
export function AnswerCompatibilityQuestionButton(props: {
|
||||
user: User | null | undefined
|
||||
@@ -22,13 +23,16 @@ export function AnswerCompatibilityQuestionButton(props: {
|
||||
} = props
|
||||
const [open, setOpen] = useState(fromSignup ?? false)
|
||||
if (!user) return null
|
||||
if (otherQuestions.length === 0) return null
|
||||
const isCore = otherQuestions.some((q) => q.importance_score === 0)
|
||||
const questionsToAnswer = isCore ? otherQuestions.filter((q) => q.importance_score === 0) : otherQuestions
|
||||
return (
|
||||
<>
|
||||
{size === 'md' ? (
|
||||
<Button onClick={() => setOpen(true)} color="gray-outline">
|
||||
Answer Questions{' '}
|
||||
<span className="text-primary-600 ml-2">
|
||||
+{otherQuestions.length}
|
||||
+{questionsToAnswer.length}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
@@ -43,8 +47,11 @@ export function AnswerCompatibilityQuestionButton(props: {
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
otherQuestions={otherQuestions}
|
||||
otherQuestions={questionsToAnswer}
|
||||
refreshCompatibilityAll={refreshCompatibilityAll}
|
||||
onClose={() => {
|
||||
if (fromSignup) router.push('/')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -83,8 +90,9 @@ function AnswerCompatibilityQuestionModal(props: {
|
||||
user: User
|
||||
otherQuestions: QuestionWithCountType[]
|
||||
refreshCompatibilityAll: () => void
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll } = props
|
||||
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose } = props
|
||||
const [questionIndex, setQuestionIndex] = useState(0)
|
||||
return (
|
||||
<Modal
|
||||
@@ -93,6 +101,7 @@ function AnswerCompatibilityQuestionModal(props: {
|
||||
onClose={() => {
|
||||
refreshCompatibilityAll()
|
||||
setQuestionIndex(0)
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
<Col className={MODAL_CLASS}>
|
||||
|
||||
@@ -36,10 +36,10 @@ type ImportanceColorsType = {
|
||||
}
|
||||
|
||||
export const IMPORTANCE_RADIO_COLORS: ImportanceColorsType = {
|
||||
0: `bg-stone-400 ring-stone-400 dark:bg-stone-500 dark:ring-stone-500`,
|
||||
1: `bg-teal-200 ring-teal-200 dark:bg-teal-100 dark:ring-teal-100 `,
|
||||
2: `bg-teal-300 ring-teal-300 dark:bg-teal-200 dark:ring-teal-200 `,
|
||||
3: `bg-teal-400 ring-teal-400`,
|
||||
0: `bg-teal-300 ring-teal-200`,
|
||||
1: `bg-teal-500 ring-teal-200`,
|
||||
2: `bg-teal-700 ring-teal-300`,
|
||||
3: `bg-teal-900 ring-teal-400`,
|
||||
}
|
||||
|
||||
export const IMPORTANCE_DISPLAY_COLORS: ImportanceColorsType = {
|
||||
@@ -157,11 +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">
|
||||
{compatibilityQuestion.importance_score > 0 && <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>
|
||||
</Row>}
|
||||
{index !== null &&
|
||||
index !== undefined &&
|
||||
total !== null &&
|
||||
|
||||
@@ -402,7 +402,7 @@ function CompatibilityAnswerBlock(props: {
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
<Row className="bg-canvas-50 w-fit gap-1 rounded px-2 py-1 text-sm">
|
||||
<Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
|
||||
{answerText}
|
||||
</Row>
|
||||
<Row className="px-2 -mt-4">
|
||||
@@ -417,11 +417,11 @@ function CompatibilityAnswerBlock(props: {
|
||||
? 'Acceptable'
|
||||
: 'Also acceptable'}
|
||||
</div>
|
||||
<Row className="flex-wrap gap-2 -mt-2">
|
||||
<Row className="flex-wrap gap-2 mt-0">
|
||||
{distinctPreferredAnswersText.map((text) => (
|
||||
<Row
|
||||
key={text}
|
||||
className="bg-canvas-50 w-fit gap-1 rounded px-2 py-1 text-sm"
|
||||
className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
{text}
|
||||
</Row>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { User } from 'common/user'
|
||||
import { useOtherAnswers } from 'web/hooks/use-other-answers'
|
||||
import { QuestionWithCountType } from 'web/hooks/use-questions'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar } from 'web/components/widgets/avatar'
|
||||
import { Linkify } from 'web/components/widgets/linkify'
|
||||
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
|
||||
import { UserLink } from 'web/components/widgets/user-link'
|
||||
import { Gender, convertGender } from 'common/gender'
|
||||
import { capitalize } from 'lodash'
|
||||
import {User} from 'common/user'
|
||||
import {useOtherAnswers} from 'web/hooks/use-other-answers'
|
||||
import {QuestionWithCountType} from 'web/hooks/use-questions'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {Avatar} from 'web/components/widgets/avatar'
|
||||
import {Linkify} from 'web/components/widgets/linkify'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {UserLink} from 'web/components/widgets/user-link'
|
||||
import {convertGender, Gender} from 'common/gender'
|
||||
import {capitalize} from 'lodash'
|
||||
import clsx from 'clsx'
|
||||
import { shortenedFromNow } from 'web/lib/util/shortenedFromNow'
|
||||
import {shortenedFromNow} from 'web/lib/util/shortenedFromNow'
|
||||
|
||||
export function OtherProfileAnswers(props: {
|
||||
question: QuestionWithCountType
|
||||
user?: User
|
||||
className?: string
|
||||
}) {
|
||||
const { question, className } = props
|
||||
const {question, className} = props
|
||||
const otherAnswers = useOtherAnswers(question.id)
|
||||
const shownAnswers = otherAnswers?.filter(
|
||||
(a) => a.multiple_choice != null || a.free_response || a.integer
|
||||
)
|
||||
|
||||
if (otherAnswers === undefined) return <LoadingIndicator />
|
||||
if (otherAnswers === undefined) return <CompassLoadingIndicator/>
|
||||
if (
|
||||
(otherAnswers === null ||
|
||||
otherAnswers.length ||
|
||||
@@ -50,7 +50,7 @@ export function OtherProfileAnswers(props: {
|
||||
/>
|
||||
<Col>
|
||||
<span className="text-sm">
|
||||
<UserLink user={answerUser} />, {otherAnswer.age}
|
||||
<UserLink user={answerUser}/>, {otherAnswer.age}
|
||||
</span>
|
||||
<Row className="gap-1 text-xs">
|
||||
{otherAnswer.city} •{' '}
|
||||
|
||||
@@ -8,10 +8,11 @@ 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 {useEffect, useState} from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import Link from "next/link"
|
||||
import {MIN_BIO_LENGTH} from "common/constants";
|
||||
import {ShowMore} from 'web/components/widgets/show-more'
|
||||
|
||||
const placeHolder = "Tell us about yourself — and what you're looking for!";
|
||||
|
||||
@@ -26,32 +27,11 @@ Write a clear and engaging bio to help others understand who you are and the con
|
||||
`
|
||||
|
||||
export function BioTips() {
|
||||
const [showMoreInfo, setShowMoreInfo] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="mt-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMoreInfo(!showMoreInfo)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"
|
||||
>
|
||||
{showMoreInfo ? 'Hide info' : 'Tips'}
|
||||
<svg
|
||||
className={`w-4 h-4 ml-1 transition-transform ${showMoreInfo ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{showMoreInfo && (
|
||||
<div className="mt-2 p-3 rounded-md text-sm customlink">
|
||||
<ReactMarkdown>{tips}</ReactMarkdown>
|
||||
<Link href="/tips-bio" target="_blank">Read full tips for writing a high-quality bio</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ShowMore labelClosed="Tips" labelOpen="Hide info" className={'customlink text-sm'}>
|
||||
<ReactMarkdown>{tips}</ReactMarkdown>
|
||||
<Link href="/tips-bio" target="_blank">Read full tips for writing a high-quality bio</Link>
|
||||
</ShowMore>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
57
web/components/contact.tsx
Normal file
57
web/components/contact.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
|
||||
import {JSONContent} from "@tiptap/core";
|
||||
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
|
||||
import {Button} from "web/components/buttons/button";
|
||||
import {api} from "web/lib/api";
|
||||
import {Title} from "web/components/widgets/title";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export function ContactComponent() {
|
||||
const user = useUser()
|
||||
|
||||
const editor = useTextEditor({
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: '',
|
||||
placeholder: 'Contact us here...',
|
||||
})
|
||||
|
||||
const hideButton = editor?.getText().length == 0
|
||||
|
||||
return (
|
||||
<Col className="mx-2">
|
||||
<Title className="!mb-2 text-3xl">Contact</Title>
|
||||
<Col>
|
||||
<div className={'mb-2'}>
|
||||
<TextEditor
|
||||
editor={editor}
|
||||
/>
|
||||
</div>
|
||||
{!hideButton && (
|
||||
<Row className="right-1 justify-between gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
if (!editor) return
|
||||
const data = {
|
||||
content: editor.getJSON() as JSONContent,
|
||||
userId: user?.id,
|
||||
};
|
||||
const result = await api('contact', data).catch(() => {
|
||||
toast.error('Failed to contact — try again or contact us...')
|
||||
})
|
||||
if (!result) return
|
||||
editor.commands.clearContent()
|
||||
toast.success('Thank you for your message!')
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,40 @@
|
||||
export const RELATIONSHIP_CHOICES = {
|
||||
// Monogamous: 'mono',
|
||||
// Polyamorous: 'poly',
|
||||
// 'Open Relationship': 'open',
|
||||
// Other: 'other',
|
||||
Collaboration: 'collaboration',
|
||||
Friendship: 'friendship',
|
||||
Relationship: 'relationship',
|
||||
};
|
||||
|
||||
export const ROMANTIC_CHOICES = {
|
||||
Monogamous: 'mono',
|
||||
Polyamorous: 'poly',
|
||||
'Open Relationship': 'open',
|
||||
};
|
||||
|
||||
export const POLITICAL_CHOICES = {
|
||||
Progressive: 'progressive',
|
||||
Liberal: 'liberal',
|
||||
'Moderate / Centrist': 'moderate',
|
||||
Conservative: 'conservative',
|
||||
Socialist: 'socialist',
|
||||
Nationalist: 'nationalist',
|
||||
Populist: 'populist',
|
||||
'Green / Eco-Socialist': 'green',
|
||||
Technocratic: 'technocratic',
|
||||
Libertarian: 'libertarian',
|
||||
'Effective Accelerationism': 'e/acc',
|
||||
'Pause AI / Tech Skeptic': 'pause ai',
|
||||
'Independent / Other': 'other',
|
||||
}
|
||||
|
||||
export const REVERTED_RELATIONSHIP_CHOICES = Object.fromEntries(
|
||||
Object.entries(RELATIONSHIP_CHOICES).map(([key, value]) => [value, key])
|
||||
);
|
||||
|
||||
export const REVERTED_ROMANTIC_CHOICES = Object.fromEntries(
|
||||
Object.entries(ROMANTIC_CHOICES).map(([key, value]) => [value, key])
|
||||
);
|
||||
|
||||
export const REVERTED_POLITICAL_CHOICES = Object.fromEntries(
|
||||
Object.entries(POLITICAL_CHOICES).map(([key, value]) => [value, key])
|
||||
);
|
||||
@@ -75,7 +75,7 @@ export function LocationFilter(props: {
|
||||
if (!city) {
|
||||
setLocation(undefined)
|
||||
} else {
|
||||
setLocation({ id: city.geodb_city_id, name: city.city })
|
||||
setLocation({ id: city.geodb_city_id, name: city.city, lat: city.latitude, lon: city.longitude })
|
||||
setLastCity(city)
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function DistanceSlider(props: {
|
||||
}) {
|
||||
const { radius, setRadius } = props
|
||||
|
||||
const snapValues = [10, 50, 100, 200, 300]
|
||||
const snapValues = [10, 50, 100, 200, 300, 500]
|
||||
|
||||
const snapToValue = (value: number) => {
|
||||
const closest = snapValues.reduce((prev, curr) =>
|
||||
@@ -158,7 +158,7 @@ function LocationResults(props: {
|
||||
}) {
|
||||
const { showAny, cities, onCitySelected, loading, className } = props
|
||||
|
||||
// delay loading animation by 150ms
|
||||
// delay loading animation by 150 ms
|
||||
const [debouncedLoading, setDebouncedLoading] = useState(loading)
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Profile} from 'common/love/profile'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {IoFilterSharp} from 'react-icons/io5'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
@@ -10,13 +10,13 @@ import {Select} from 'web/components/widgets/select'
|
||||
import {DesktopFilters} from './desktop-filters'
|
||||
import {LocationFilterProps} from './location-filter'
|
||||
import {MobileFilters} from './mobile-filters'
|
||||
import {BookmarkSearchButton} from "web/components/searches/button";
|
||||
import {BookmarkSearchButton, BookmarkStarButton} from "web/components/searches/button";
|
||||
import {BookmarkedSearchesType} from "web/hooks/use-bookmarked-searches";
|
||||
import {submitBookmarkedSearch} from "web/lib/supabase/searches";
|
||||
import {useUser} from "web/hooks/use-user";
|
||||
import {isEqual} from "lodash";
|
||||
import toast from "react-hot-toast";
|
||||
import {FilterFields, initialFilters} from "common/filters";
|
||||
import {FilterFields} from "common/filters";
|
||||
import {DisplayUser} from "common/api/user-types";
|
||||
|
||||
function isOrderBy(input: string): input is FilterFields['orderBy'] {
|
||||
return ['last_online_time', 'created_time', 'compatibility_score'].includes(
|
||||
@@ -32,21 +32,16 @@ export const WORDS: string[] = [
|
||||
"Sustainability",
|
||||
"Veganism",
|
||||
"Meditation",
|
||||
"Climate action",
|
||||
"Animal welfare",
|
||||
"Climate",
|
||||
"Animal",
|
||||
"Community living",
|
||||
"Open source",
|
||||
"Spirituality",
|
||||
"Mutual aid",
|
||||
|
||||
// Intellectual interests
|
||||
"Philosophy",
|
||||
"AI safety",
|
||||
"Effective altruism",
|
||||
"Systems thinking",
|
||||
"Psychology",
|
||||
"Thinking, Fast and Slow",
|
||||
"History of ideas",
|
||||
|
||||
// Arts & culture
|
||||
"Indie film",
|
||||
@@ -54,32 +49,42 @@ export const WORDS: string[] = [
|
||||
"Contemporary art",
|
||||
"Folk music",
|
||||
"Poetry",
|
||||
"Sci-fi novels",
|
||||
"Sci-fi",
|
||||
"Board games",
|
||||
|
||||
// Relationship intentions
|
||||
"Platonic friendship",
|
||||
"Romantic partner",
|
||||
"Collaborator",
|
||||
"Study buddy",
|
||||
"Co-founder",
|
||||
|
||||
// Lifestyle
|
||||
"Digital nomad",
|
||||
"Slow travel",
|
||||
"Forest co-ops",
|
||||
"Permaculture",
|
||||
"Yoga retreats",
|
||||
"Mindful parenting",
|
||||
"Non-smoker",
|
||||
"Yoga",
|
||||
|
||||
// Random human quirks (to make it feel alive)
|
||||
"Chess",
|
||||
"Rock climbing",
|
||||
"Cold plunges",
|
||||
"Tea ceremonies",
|
||||
"Stargazing",
|
||||
"Urban gardening",
|
||||
|
||||
// Other
|
||||
"Feminism",
|
||||
"Coding",
|
||||
"ENFP",
|
||||
"INTP",
|
||||
"Therapy",
|
||||
"Science",
|
||||
"Camus",
|
||||
"Running",
|
||||
"Writing",
|
||||
"Reading",
|
||||
"Anime",
|
||||
"Drawing",
|
||||
"Photography",
|
||||
"Linux",
|
||||
"History",
|
||||
"Graphics design",
|
||||
"Math",
|
||||
"Ethereum",
|
||||
]
|
||||
|
||||
function getRandomPair(count = 3): string {
|
||||
@@ -91,7 +96,8 @@ function getRandomPair(count = 3): string {
|
||||
const MAX_BOOKMARKED_SEARCHES = 10;
|
||||
export const Search = (props: {
|
||||
youProfile: Profile | undefined | null
|
||||
starredUserIds: string[]
|
||||
starredUsers: DisplayUser[]
|
||||
refreshStars: () => void
|
||||
// filter props
|
||||
filters: Partial<FilterFields>
|
||||
updateFilter: (newState: Partial<FilterFields>) => void
|
||||
@@ -112,6 +118,8 @@ export const Search = (props: {
|
||||
filters,
|
||||
bookmarkedSearches,
|
||||
refreshBookmarkedSearches,
|
||||
starredUsers,
|
||||
refreshStars,
|
||||
} = props
|
||||
|
||||
const [openFiltersModal, setOpenFiltersModal] = useState(false)
|
||||
@@ -123,6 +131,7 @@ export const Search = (props: {
|
||||
const [bookmarked, setBookmarked] = useState(false);
|
||||
const [loadingBookmark, setLoadingBookmark] = useState(false);
|
||||
const [openBookmarks, setOpenBookmarks] = useState(false);
|
||||
const [openStarBookmarks, setOpenStarBookmarks] = useState(false);
|
||||
const user = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -226,9 +235,7 @@ export const Search = (props: {
|
||||
</RightModal>
|
||||
<Row className={'mb-2 gap-2'}>
|
||||
<Button
|
||||
disabled={
|
||||
loadingBookmark || isEqual(filters, initialFilters)
|
||||
}
|
||||
disabled={loadingBookmark}
|
||||
loading={loadingBookmark}
|
||||
onClick={() => {
|
||||
if (bookmarkedSearches.length >= MAX_BOOKMARKED_SEARCHES) {
|
||||
@@ -249,7 +256,7 @@ export const Search = (props: {
|
||||
color={'none'}
|
||||
className={'bg-canvas-100 hover:bg-canvas-200'}
|
||||
>
|
||||
{bookmarked ? 'Bookmarked!' : loadingBookmark ? '' : 'Get Notified'}
|
||||
{bookmarked ? 'Saved!' : loadingBookmark ? '' : 'Get Notified'}
|
||||
</Button>
|
||||
|
||||
<BookmarkSearchButton
|
||||
@@ -258,6 +265,16 @@ export const Search = (props: {
|
||||
open={openBookmarks}
|
||||
setOpen={setOpenBookmarks}
|
||||
/>
|
||||
|
||||
<BookmarkStarButton
|
||||
refreshStars={refreshStars}
|
||||
starredUsers={starredUsers}
|
||||
open={openStarBookmarks}
|
||||
setOpen={(checked) => {
|
||||
setOpenStarBookmarks(checked)
|
||||
refreshStars()
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {Profile} from "common/love/profile";
|
||||
import {useIsLooking} from "web/hooks/use-is-looking";
|
||||
import {usePersistentLocalState} from "web/hooks/use-persistent-local-state";
|
||||
import {useCallback} from "react";
|
||||
import {useCallback, useEffect} from "react";
|
||||
import {debounce, isEqual} from "lodash";
|
||||
import {useNearbyCities} from "web/hooks/use-nearby-locations";
|
||||
import {useEffectCheckEquality} from "web/hooks/use-effect-check-equality";
|
||||
import {wantsKidsDatabase, wantsKidsDatabaseToWantsKidsFilter, wantsKidsToHasKidsFilter} from "common/wants-kids";
|
||||
import {FilterFields, initialFilters, OriginLocation} from "common/filters";
|
||||
import {MAX_INT, MIN_INT} from "common/constants";
|
||||
@@ -13,9 +11,11 @@ export const useFilters = (you: Profile | undefined) => {
|
||||
const isLooking = useIsLooking()
|
||||
const [filters, setFilters] = usePersistentLocalState<Partial<FilterFields>>(
|
||||
isLooking ? initialFilters : {...initialFilters, orderBy: 'created_time'},
|
||||
'profile-filters-2'
|
||||
'profile-filters-4'
|
||||
)
|
||||
|
||||
// console.log('filters', filters)
|
||||
|
||||
const updateFilter = (newState: Partial<FilterFields>) => {
|
||||
const updatedState = {...newState}
|
||||
|
||||
@@ -31,6 +31,8 @@ export const useFilters = (you: Profile | undefined) => {
|
||||
}
|
||||
}
|
||||
|
||||
// console.log('updating filters', updatedState)
|
||||
|
||||
setFilters((prevState) => ({...prevState, ...updatedState}))
|
||||
}
|
||||
|
||||
@@ -54,11 +56,17 @@ export const useFilters = (you: Profile | undefined) => {
|
||||
OriginLocation | undefined | null
|
||||
>(undefined, 'nearby-origin-location')
|
||||
|
||||
const nearbyCities = useNearbyCities(location?.id, radius)
|
||||
// const nearbyCities = useNearbyCities(location?.id, radius)
|
||||
//
|
||||
// useEffectCheckEquality(() => {
|
||||
// updateFilter({geodbCityIds: nearbyCities})
|
||||
// }, [nearbyCities])
|
||||
|
||||
useEffectCheckEquality(() => {
|
||||
updateFilter({geodbCityIds: nearbyCities})
|
||||
}, [nearbyCities])
|
||||
useEffect(() => {
|
||||
if (location?.lat && location?.lon) {
|
||||
updateFilter({lat: location.lat, lon: location.lon, radius: radius})
|
||||
}
|
||||
}, [location?.id, radius]);
|
||||
|
||||
const locationFilterProps = {
|
||||
location,
|
||||
@@ -99,8 +107,8 @@ export const useFilters = (you: Profile | undefined) => {
|
||||
updateFilter(yourFilters)
|
||||
setRadius(100)
|
||||
debouncedSetRadius(100) // clear any pending debounced sets
|
||||
if (you?.geodb_city_id && you.city) {
|
||||
setLocation({id: you?.geodb_city_id, name: you?.city})
|
||||
if (you?.geodb_city_id && you.city && you.city_latitude && you.city_longitude) {
|
||||
setLocation({id: you?.geodb_city_id, name: you?.city, lat: you?.city_latitude, lon: you?.city_longitude})
|
||||
}
|
||||
} else {
|
||||
clearFilters()
|
||||
|
||||
@@ -24,6 +24,8 @@ 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";
|
||||
import {MdThumbUp} from "react-icons/md";
|
||||
import {FaEnvelope} from "react-icons/fa";
|
||||
|
||||
export function LovePage(props: {
|
||||
trackPageView: string | false
|
||||
@@ -113,12 +115,16 @@ const NotifsSolid = {name: 'Notifs', href: `/notifications`, icon: SolidNotifica
|
||||
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 Vote = {name: 'Vote', href: '/vote', icon: MdThumbUp};
|
||||
const Contact = {name: 'Contact', href: '/contact', icon: FaEnvelope};
|
||||
|
||||
const base = [
|
||||
About,
|
||||
faq,
|
||||
Vote,
|
||||
Social,
|
||||
Organization,
|
||||
Contact,
|
||||
]
|
||||
|
||||
function getBottomNavigation(user: User, profile: Profile | null | undefined) {
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function MarkdownPage({content, filename}: Props) {
|
||||
<Col className='w-full rounded px-3 py-4 sm:px-6 space-y-4 customlink'>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
a: ({node, children, ...props}) => <MarkdownLink {...props}>{children}</MarkdownLink>
|
||||
a: ({node: _node, children, ...props}) => <MarkdownLink {...props}>{children}</MarkdownLink>
|
||||
}}
|
||||
>{content}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -86,7 +86,6 @@ const bottomNav = (
|
||||
toggleTheme: () => void
|
||||
) =>
|
||||
buildArray<Item>(
|
||||
!loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' },
|
||||
{
|
||||
name: theme ?? 'auto',
|
||||
children:
|
||||
@@ -114,6 +113,7 @@ const bottomNav = (
|
||||
),
|
||||
onClick: toggleTheme,
|
||||
},
|
||||
!loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' },
|
||||
loggedIn && { name: 'Sign out', icon: LogoutIcon, onClick: logout }
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip } from 'web/components/widgets/tooltip'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
|
||||
export const OnlineIcon = memo(function OnlineIcon(props: {
|
||||
last_online_time: string
|
||||
last_online_time?: string
|
||||
}) {
|
||||
const { last_online_time } = props
|
||||
|
||||
@@ -15,21 +15,21 @@ export const OnlineIcon = memo(function OnlineIcon(props: {
|
||||
const currentTime = dayjs()
|
||||
|
||||
// Calculate the time difference as a duration
|
||||
const diff = dayjs.duration(Math.abs(currentTime.diff(lastOnlineTime)))
|
||||
const timeDiff = dayjs.duration(Math.abs(currentTime.diff(lastOnlineTime)))
|
||||
|
||||
const STALLED_CUTOFF = 15
|
||||
const INACTIVE_HOURS = 12
|
||||
|
||||
// Check if last online time was more than 30 minutes ago and more than one day ago
|
||||
const isStalled = diff.asMinutes() > STALLED_CUTOFF
|
||||
const isInactive = diff.asHours() > INACTIVE_HOURS
|
||||
const isStalled = timeDiff.asMinutes() > STALLED_CUTOFF
|
||||
const isInactive = timeDiff.asHours() > INACTIVE_HOURS
|
||||
|
||||
if (isInactive) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip text={'Last online: ' + fromNow(lastOnlineTime.valueOf())}>
|
||||
<Tooltip text={'Last online: ' + fromNow(last_online_time)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'h-2 w-2 rounded-full',
|
||||
|
||||
@@ -28,7 +28,7 @@ import {City, CityRow, profileToCity, useCitySearch} from "web/components/search
|
||||
import {AddPhotosWidget} from './widgets/add-photos'
|
||||
import {RadioToggleGroup} from "web/components/widgets/radio-toggle-group";
|
||||
import {MultipleChoiceOptions} from "common/love/multiple-choice";
|
||||
import {RELATIONSHIP_CHOICES} from "web/components/filters/choices";
|
||||
import {POLITICAL_CHOICES, RELATIONSHIP_CHOICES, ROMANTIC_CHOICES} from "web/components/filters/choices";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const OptionalLoveUserForm = (props: {
|
||||
@@ -42,7 +42,7 @@ export const OptionalLoveUserForm = (props: {
|
||||
const {profile, user, buttonLabel, setProfile, fromSignup, onSubmit} = props
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [lookingRelationship, setLookingRelationship] = useState(false)
|
||||
const [lookingRelationship, setLookingRelationship] = useState((profile.pref_relation_styles || []).includes('relationship'))
|
||||
const router = useRouter()
|
||||
const [heightFeet, setHeightFeet] = useState<number | undefined>(
|
||||
profile.height_in_inches
|
||||
@@ -276,12 +276,55 @@ export const OptionalLoveUserForm = (props: {
|
||||
<MultiCheckbox
|
||||
choices={RELATIONSHIP_CHOICES}
|
||||
selected={profile['pref_relation_styles']}
|
||||
onChange={(selected) =>
|
||||
onChange={(selected) => {
|
||||
setProfile('pref_relation_styles', selected)
|
||||
}
|
||||
setLookingRelationship((selected || []).includes('relationship'))
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{lookingRelationship && <>
|
||||
<Col className={clsx(colClassName)}>
|
||||
<label className={clsx(labelClassName)}>Relationship style</label>
|
||||
<MultiCheckbox
|
||||
choices={ROMANTIC_CHOICES}
|
||||
selected={profile['pref_romantic_styles'] || []}
|
||||
onChange={(selected) => {
|
||||
setProfile('pref_romantic_styles', selected)
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col className={clsx(colClassName)}>
|
||||
<label className={clsx(labelClassName)}>
|
||||
I would like to have kids
|
||||
</label>
|
||||
<RadioToggleGroup
|
||||
className={'w-44'}
|
||||
choicesMap={MultipleChoiceOptions}
|
||||
setChoice={(choice) => {
|
||||
setProfile('wants_kids_strength', choice)
|
||||
}}
|
||||
currentChoice={profile.wants_kids_strength ?? -1}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col className={clsx(colClassName)}>
|
||||
<label className={clsx(labelClassName)}>Current number of kids</label>
|
||||
<Input
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
e.target.value === '' ? null : Number(e.target.value)
|
||||
setProfile('has_kids', value)
|
||||
}}
|
||||
className={'w-20'}
|
||||
min={0}
|
||||
value={profile['has_kids'] ?? undefined}
|
||||
/>
|
||||
</Col>
|
||||
</>}
|
||||
|
||||
<Col className={clsx(colClassName, 'pb-4')}>
|
||||
<label className={clsx(labelClassName)}>Socials</label>
|
||||
|
||||
@@ -350,16 +393,7 @@ export const OptionalLoveUserForm = (props: {
|
||||
<Col className={clsx(colClassName)}>
|
||||
<label className={clsx(labelClassName)}>Political beliefs</label>
|
||||
<MultiCheckbox
|
||||
choices={{
|
||||
Liberal: 'liberal',
|
||||
Moderate: 'moderate',
|
||||
Conservative: 'conservative',
|
||||
Socialist: 'socialist',
|
||||
Libertarian: 'libertarian',
|
||||
'e/acc': 'e/acc',
|
||||
'Pause AI': 'pause ai',
|
||||
Other: 'other',
|
||||
}}
|
||||
choices={POLITICAL_CHOICES}
|
||||
selected={profile['political_beliefs'] ?? []}
|
||||
onChange={(selected) => setProfile('political_beliefs', selected)}
|
||||
/>
|
||||
@@ -515,45 +549,14 @@ export const OptionalLoveUserForm = (props: {
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col className={clsx(colClassName)}>
|
||||
<label className={clsx(labelClassName)}>Current number of kids</label>
|
||||
<Input
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
e.target.value === '' ? null : Number(e.target.value)
|
||||
setProfile('has_kids', value)
|
||||
}}
|
||||
className={'w-20'}
|
||||
min={0}
|
||||
value={profile['has_kids'] ?? undefined}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col className={clsx(colClassName)}>
|
||||
<label className={clsx(labelClassName)}>Looking for a relationship?</label>
|
||||
<ChoicesToggleGroup
|
||||
currentChoice={lookingRelationship}
|
||||
choicesMap={{Yes: true, No: false}}
|
||||
setChoice={(c) => setLookingRelationship(c)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{lookingRelationship && <>
|
||||
<Col className={clsx(colClassName)}>
|
||||
<label className={clsx(labelClassName)}>
|
||||
I would like to have kids
|
||||
</label>
|
||||
<RadioToggleGroup
|
||||
className={'w-44'}
|
||||
choicesMap={MultipleChoiceOptions}
|
||||
setChoice={(choice) => {
|
||||
setProfile('wants_kids_strength', choice)
|
||||
}}
|
||||
currentChoice={profile.wants_kids_strength ?? -1}
|
||||
/>
|
||||
</Col>
|
||||
</>}
|
||||
{/*<Col className={clsx(colClassName)}>*/}
|
||||
{/* <label className={clsx(labelClassName)}>Looking for a relationship?</label>*/}
|
||||
{/* <ChoicesToggleGroup*/}
|
||||
{/* currentChoice={lookingRelationship}*/}
|
||||
{/* choicesMap={{Yes: true, No: false}}*/}
|
||||
{/* setChoice={(c) => setLookingRelationship(c)}*/}
|
||||
{/* />*/}
|
||||
{/*</Col>*/}
|
||||
|
||||
<Col className={clsx(colClassName)}>
|
||||
<label className={clsx(labelClassName)}>Photos</label>
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
type RelationshipType,
|
||||
convertRelationshipType,
|
||||
} from 'web/lib/util/convert-relationship-type'
|
||||
import {convertRelationshipType, type RelationshipType,} from 'web/lib/util/convert-relationship-type'
|
||||
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
|
||||
import { ReactNode } from 'react'
|
||||
import { BiSolidDrink } from 'react-icons/bi'
|
||||
import { BsPersonHeart } from 'react-icons/bs'
|
||||
import { FaChild } from 'react-icons/fa6'
|
||||
import {
|
||||
LuBriefcase,
|
||||
LuCigarette,
|
||||
LuCigaretteOff,
|
||||
LuGraduationCap,
|
||||
} from 'react-icons/lu'
|
||||
import { MdNoDrinks, MdOutlineChildFriendly } from 'react-icons/md'
|
||||
import {
|
||||
PiHandsPrayingBold,
|
||||
PiMagnifyingGlassBold,
|
||||
PiPlantBold,
|
||||
} from 'react-icons/pi'
|
||||
import { RiScales3Line } from 'react-icons/ri'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
import { Gender, convertGenderPlural } from 'common/gender'
|
||||
import { HiOutlineGlobe } from 'react-icons/hi'
|
||||
import { UserHandles } from 'web/components/user/user-handles'
|
||||
import { convertRace } from './race'
|
||||
import { Profile } from 'common/love/profile'
|
||||
import {ReactNode} from 'react'
|
||||
import {REVERTED_POLITICAL_CHOICES, REVERTED_ROMANTIC_CHOICES} from 'web/components/filters/choices'
|
||||
import {BiSolidDrink} from 'react-icons/bi'
|
||||
import {BsPersonHeart} from 'react-icons/bs'
|
||||
import {FaChild} from 'react-icons/fa6'
|
||||
import {LuBriefcase, LuCigarette, LuCigaretteOff, LuGraduationCap,} from 'react-icons/lu'
|
||||
import {MdNoDrinks, MdOutlineChildFriendly} from 'react-icons/md'
|
||||
import {PiHandsPrayingBold, PiMagnifyingGlassBold, PiPlantBold,} from 'react-icons/pi'
|
||||
import {RiScales3Line} from 'react-icons/ri'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {fromNow} from 'web/lib/util/time'
|
||||
import {convertGenderPlural, Gender} from 'common/gender'
|
||||
import {HiOutlineGlobe} from 'react-icons/hi'
|
||||
import {UserHandles} from 'web/components/user/user-handles'
|
||||
import {convertRace} from './race'
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {UserActivity} from "common/user";
|
||||
import {ClockIcon} from "@heroicons/react/solid";
|
||||
|
||||
export function AboutRow(props: {
|
||||
icon: ReactNode
|
||||
text?: string | null | string[]
|
||||
preText?: string
|
||||
}) {
|
||||
const { icon, text, preText } = props
|
||||
const {icon, text, preText} = props
|
||||
if (!text || text.length < 1) {
|
||||
return <></>
|
||||
}
|
||||
@@ -54,45 +45,50 @@ export function AboutRow(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfileAbout(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
export default function ProfileAbout(props: {
|
||||
profile: Profile,
|
||||
userActivity?: UserActivity,
|
||||
isCurrentUser: boolean,
|
||||
}) {
|
||||
const {profile, userActivity, isCurrentUser} = props
|
||||
return (
|
||||
<Col
|
||||
className={clsx('bg-canvas-0 relative gap-3 overflow-hidden rounded p-4')}
|
||||
>
|
||||
<Seeking profile={profile} />
|
||||
<RelationshipType profile={profile} />
|
||||
<HasKids profile={profile} />
|
||||
<Seeking profile={profile}/>
|
||||
<RelationshipType profile={profile}/>
|
||||
<HasKids profile={profile}/>
|
||||
<AboutRow
|
||||
icon={<RiScales3Line className="h-5 w-5" />}
|
||||
text={profile.political_beliefs}
|
||||
icon={<RiScales3Line className="h-5 w-5"/>}
|
||||
text={profile.political_beliefs?.map(belief => REVERTED_POLITICAL_CHOICES[belief])}
|
||||
/>
|
||||
<Education profile={profile} />
|
||||
<Occupation profile={profile} />
|
||||
<Education profile={profile}/>
|
||||
<Occupation profile={profile}/>
|
||||
<AboutRow
|
||||
icon={<PiHandsPrayingBold className="h-5 w-5" />}
|
||||
icon={<PiHandsPrayingBold className="h-5 w-5"/>}
|
||||
text={profile.religious_beliefs}
|
||||
/>
|
||||
<AboutRow
|
||||
icon={<HiOutlineGlobe className="h-5 w-5" />}
|
||||
icon={<HiOutlineGlobe className="h-5 w-5"/>}
|
||||
text={profile.ethnicity
|
||||
?.filter((r) => r !== 'other')
|
||||
?.map((r: any) => convertRace(r))}
|
||||
/>
|
||||
<Smoker profile={profile} />
|
||||
<Drinks profile={profile} />
|
||||
<Smoker profile={profile}/>
|
||||
<Drinks profile={profile}/>
|
||||
<AboutRow
|
||||
icon={<PiPlantBold className="h-5 w-5" />}
|
||||
icon={<PiPlantBold className="h-5 w-5"/>}
|
||||
text={profile.is_vegetarian_or_vegan ? 'Vegetarian/Vegan' : null}
|
||||
/>
|
||||
<WantsKids profile={profile} />
|
||||
<UserHandles links={profile.user.link} />
|
||||
<WantsKids profile={profile}/>
|
||||
{!isCurrentUser && <LastOnline lastOnlineTime={userActivity?.last_online_time}/>}
|
||||
<UserHandles links={profile.user.link}/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function Seeking(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
const {profile} = props
|
||||
const prefGender = profile.pref_gender
|
||||
const min = profile.pref_age_min
|
||||
const max = profile.pref_age_max
|
||||
@@ -110,28 +106,28 @@ function Seeking(props: { profile: Profile }) {
|
||||
min == 18 && max == 100 || min == undefined && max == undefined
|
||||
? 'of any age'
|
||||
: min == max
|
||||
? `exactly ${min} years old`
|
||||
: max == 100 || max == undefined
|
||||
? `older than ${min}`
|
||||
: min == 18 || min == undefined
|
||||
? `younger than ${max}`
|
||||
: `between ${min} - ${max} years old`
|
||||
? `exactly ${min} years old`
|
||||
: max == 100 || max == undefined
|
||||
? `older than ${min}`
|
||||
: min == 18 || min == undefined
|
||||
? `younger than ${max}`
|
||||
: `between ${min} - ${max} years old`
|
||||
|
||||
if (!prefGender || prefGender.length < 1) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<PiMagnifyingGlassBold className="h-5 w-5" />}
|
||||
icon={<PiMagnifyingGlassBold className="h-5 w-5"/>}
|
||||
text={`${seekingGenderText} ${ageRangeText}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipType(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
const {profile} = props
|
||||
const relationshipTypes = profile.pref_relation_styles
|
||||
const seekingGenderText = stringOrStringArrayToText({
|
||||
let seekingGenderText = stringOrStringArrayToText({
|
||||
text: relationshipTypes.map((rel) =>
|
||||
convertRelationshipType(rel as RelationshipType).toLowerCase()
|
||||
).sort(),
|
||||
@@ -143,16 +139,25 @@ function RelationshipType(props: { profile: Profile }) {
|
||||
asSentence: true,
|
||||
capitalizeFirstLetterOption: false,
|
||||
})
|
||||
if (relationshipTypes.includes('relationship')) {
|
||||
const romanticStyles = profile.pref_romantic_styles
|
||||
?.map((style) => REVERTED_ROMANTIC_CHOICES[style].toLowerCase())
|
||||
.filter(Boolean)
|
||||
if (romanticStyles && romanticStyles.length > 0) {
|
||||
seekingGenderText += ` (${romanticStyles.join(', ')})`
|
||||
}
|
||||
|
||||
}
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<BsPersonHeart className="h-5 w-5" />}
|
||||
icon={<BsPersonHeart className="h-5 w-5"/>}
|
||||
text={seekingGenderText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Education(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
const {profile} = props
|
||||
const educationLevel = profile.education_level
|
||||
const university = profile.university
|
||||
|
||||
@@ -169,14 +174,14 @@ function Education(props: { profile: Profile }) {
|
||||
}${capitalizeAndRemoveUnderscores(university)}`
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<LuGraduationCap className="h-5 w-5" />}
|
||||
icon={<LuGraduationCap className="h-5 w-5"/>}
|
||||
text={universityText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Occupation(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
const {profile} = props
|
||||
const occupation_title = profile.occupation_title
|
||||
const company = profile.company
|
||||
|
||||
@@ -190,44 +195,44 @@ function Occupation(props: { profile: Profile }) {
|
||||
}`
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<LuBriefcase className="h-5 w-5" />}
|
||||
icon={<LuBriefcase className="h-5 w-5"/>}
|
||||
text={occupationText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Smoker(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
const {profile} = props
|
||||
const isSmoker = profile.is_smoker
|
||||
if (isSmoker == null) return null
|
||||
if (isSmoker) {
|
||||
return (
|
||||
<AboutRow icon={<LuCigarette className="h-5 w-5" />} text={'Smokes'} />
|
||||
<AboutRow icon={<LuCigarette className="h-5 w-5"/>} text={'Smokes'}/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<LuCigaretteOff className="h-5 w-5" />}
|
||||
icon={<LuCigaretteOff className="h-5 w-5"/>}
|
||||
text={`Doesn't smoke`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Drinks(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
const {profile} = props
|
||||
const drinksPerMonth = profile.drinks_per_month
|
||||
if (drinksPerMonth == null) return null
|
||||
if (drinksPerMonth === 0) {
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<MdNoDrinks className="h-5 w-5" />}
|
||||
icon={<MdNoDrinks className="h-5 w-5"/>}
|
||||
text={`Doesn't drink`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<BiSolidDrink className="h-5 w-5" />}
|
||||
icon={<BiSolidDrink className="h-5 w-5"/>}
|
||||
text={`${drinksPerMonth} ${
|
||||
drinksPerMonth == 1 ? 'drink' : 'drinks'
|
||||
} per month`}
|
||||
@@ -236,36 +241,48 @@ function Drinks(props: { profile: Profile }) {
|
||||
}
|
||||
|
||||
function WantsKids(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
const {profile} = props
|
||||
const wantsKidsStrength = profile.wants_kids_strength
|
||||
if (wantsKidsStrength == null || wantsKidsStrength < 0) return null
|
||||
const wantsKidsText =
|
||||
wantsKidsStrength == 0
|
||||
? 'Does not want children'
|
||||
: wantsKidsStrength == 1
|
||||
? 'Prefers not to have children'
|
||||
: wantsKidsStrength == 2
|
||||
? 'Neutral or open to having children'
|
||||
: wantsKidsStrength == 3
|
||||
? 'Leaning towards wanting children'
|
||||
: 'Wants children'
|
||||
? 'Prefers not to have children'
|
||||
: wantsKidsStrength == 2
|
||||
? 'Neutral or open to having children'
|
||||
: wantsKidsStrength == 3
|
||||
? 'Leaning towards wanting children'
|
||||
: 'Wants children'
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<MdOutlineChildFriendly className="h-5 w-5" />}
|
||||
icon={<MdOutlineChildFriendly className="h-5 w-5"/>}
|
||||
text={wantsKidsText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LastOnline(props: { lastOnlineTime?: string }) {
|
||||
const {lastOnlineTime} = props
|
||||
if (!lastOnlineTime) return null
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<ClockIcon className="h-5 w-5"/>}
|
||||
text={'Last online ' + fromNow(lastOnlineTime)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HasKids(props: { profile: Profile }) {
|
||||
const { profile } = props
|
||||
const {profile} = props
|
||||
const hasKidsText =
|
||||
profile.has_kids && profile.has_kids > 0
|
||||
? `Has ${profile.has_kids} ${profile.has_kids > 1 ? 'kids' : 'kid'}`
|
||||
: null
|
||||
return <AboutRow icon={<FaChild className="h-5 w-5" />} text={hasKidsText} />
|
||||
return <AboutRow icon={<FaChild className="h-5 w-5"/>} text={hasKidsText}/>
|
||||
}
|
||||
|
||||
export const formatProfileValue = (key: string, value: any) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ')
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {CompatibilityScore} from 'common/love/compatibility-score'
|
||||
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer'
|
||||
import {track} from 'web/lib/service/analytics'
|
||||
import {Col} from './layout/col'
|
||||
import clsx from 'clsx'
|
||||
import {JSONContent} from "@tiptap/core";
|
||||
import {Content} from "web/components/widgets/editor";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import {Row} from "web/components/layout/row";
|
||||
import {CompatibleBadge} from "web/components/widgets/compatible-badge";
|
||||
@@ -60,14 +59,14 @@ export const ProfileGrid = (props: {
|
||||
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<LoadingIndicator/>
|
||||
<CompassLoadingIndicator/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMore && !isReloading && other_profiles.length === 0 && (
|
||||
<div className="py-8 text-center">
|
||||
<p>No profiles found.</p>
|
||||
<p>Feel free to click on Get Notified 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 your search!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import clsx from 'clsx'
|
||||
import Router from 'next/router'
|
||||
import router from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import {User} from 'common/user'
|
||||
import {User, UserActivity} from 'common/user'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {MoreOptionsUserButton} from 'web/components/buttons/more-options-user-button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
@@ -17,15 +17,16 @@ import {ShareProfileButton} from '../widgets/share-profile-button'
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {linkClass} from 'web/components/widgets/site-link'
|
||||
import {StarButton} from '../widgets/star-button'
|
||||
import {updateProfile} from 'web/lib/api'
|
||||
import React, {useState} from 'react'
|
||||
import {useState} from 'react'
|
||||
import {VisibilityConfirmationModal} from './visibility-confirmation-modal'
|
||||
import {deleteAccount} from "web/lib/util/delete";
|
||||
import toast from "react-hot-toast";
|
||||
import {StarButton} from "web/components/widgets/star-button";
|
||||
|
||||
export default function ProfileHeader(props: {
|
||||
user: User
|
||||
userActivity?: UserActivity
|
||||
profile: Profile
|
||||
simpleView?: boolean
|
||||
starredUserIds: string[]
|
||||
@@ -36,6 +37,7 @@ export default function ProfileHeader(props: {
|
||||
const {
|
||||
user,
|
||||
profile,
|
||||
userActivity,
|
||||
simpleView,
|
||||
starredUserIds,
|
||||
refreshStars,
|
||||
@@ -46,7 +48,7 @@ export default function ProfileHeader(props: {
|
||||
const isCurrentUser = currentUser?.id === user.id
|
||||
const [showVisibilityModal, setShowVisibilityModal] = useState(false)
|
||||
|
||||
console.debug('ProfileProfileHeader', {user, profile, currentUser})
|
||||
console.debug('ProfileProfileHeader', {user, profile, userActivity, currentUser})
|
||||
|
||||
return (
|
||||
<Col className="w-full">
|
||||
@@ -54,7 +56,7 @@ export default function ProfileHeader(props: {
|
||||
<Row className="items-center gap-1">
|
||||
<Col className="gap-1">
|
||||
<Row className="items-center gap-1 text-xl">
|
||||
<OnlineIcon last_online_time={profile.last_online_time}/>
|
||||
{!isCurrentUser && <OnlineIcon last_online_time={userActivity?.last_online_time}/>}
|
||||
<span>
|
||||
{simpleView ? (
|
||||
<Link className={linkClass} href={`/${user.username}`}>
|
||||
@@ -144,13 +146,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}/>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,8 @@ import {useGetter} from 'web/hooks/use-getter'
|
||||
import {getStars} from 'web/lib/supabase/stars'
|
||||
import {Content} from "web/components/widgets/editor";
|
||||
import {JSONContent} from "@tiptap/core";
|
||||
import React from "react";
|
||||
import {useUserActivity} from 'web/hooks/use-user-activity'
|
||||
import {UserActivity} from "common/user";
|
||||
|
||||
export function ProfileInfo(props: {
|
||||
profile: Profile
|
||||
@@ -30,11 +31,12 @@ export function ProfileInfo(props: {
|
||||
// const currentProfile = useProfile()
|
||||
// const isCurrentUser = currentUser?.id === user.id
|
||||
|
||||
const {data: starredUserIds, refresh: refreshStars} = useGetter(
|
||||
const {data: starredUsers, refresh: refreshStars} = useGetter(
|
||||
'stars',
|
||||
currentUser?.id,
|
||||
getStars
|
||||
)
|
||||
const starredUserIds = starredUsers?.map((u) => u.id)
|
||||
|
||||
// const { data, refresh } = useAPIGetter('get-likes-and-ships', {
|
||||
// userId: user.id,
|
||||
@@ -61,10 +63,13 @@ export function ProfileInfo(props: {
|
||||
|
||||
const isProfileVisible = currentUser || profile.visibility === 'public'
|
||||
|
||||
const { data: userActivity } = useUserActivity(user?.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileHeader
|
||||
user={user}
|
||||
userActivity={userActivity}
|
||||
profile={profile}
|
||||
simpleView={!!fromProfilePage}
|
||||
starredUserIds={starredUserIds ?? []}
|
||||
@@ -75,6 +80,7 @@ export function ProfileInfo(props: {
|
||||
{isProfileVisible ? (
|
||||
<ProfileContent
|
||||
user={user}
|
||||
userActivity={userActivity}
|
||||
profile={profile}
|
||||
refreshProfile={refreshProfile}
|
||||
fromProfilePage={fromProfilePage}
|
||||
@@ -126,6 +132,7 @@ export function ProfileInfo(props: {
|
||||
|
||||
function ProfileContent(props: {
|
||||
user: User
|
||||
userActivity?: UserActivity
|
||||
profile: Profile
|
||||
refreshProfile: () => void
|
||||
fromProfilePage?: Profile
|
||||
@@ -138,6 +145,7 @@ function ProfileContent(props: {
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
userActivity,
|
||||
profile,
|
||||
refreshProfile,
|
||||
fromProfilePage,
|
||||
@@ -154,7 +162,7 @@ function ProfileContent(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileAbout profile={profile}/>
|
||||
<ProfileAbout profile={profile} userActivity={userActivity} isCurrentUser={isCurrentUser}/>
|
||||
<ProfileBio
|
||||
isCurrentUser={isCurrentUser}
|
||||
profile={profile}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from 'web/components/buttons/button'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { api } from 'web/lib/api'
|
||||
import {randomString} from "common/util/random";
|
||||
|
||||
export const ReportUser = (props: { user: User; closeModal: () => void }) => {
|
||||
const { user, closeModal } = props
|
||||
@@ -31,7 +32,7 @@ export const ReportUser = (props: { user: User; closeModal: () => void }) => {
|
||||
.promise(
|
||||
api('report', {
|
||||
contentType: 'user',
|
||||
contentId: user.id,
|
||||
contentId: randomString(16),
|
||||
contentOwnerId: user.id,
|
||||
description:
|
||||
'Reasons: ' + [...selectedReportTypes, otherReportType].join(', '),
|
||||
|
||||
@@ -6,14 +6,13 @@ import {useCompatibleProfiles} from 'web/hooks/use-profiles'
|
||||
import {getStars} from 'web/lib/supabase/stars'
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {ProfileGrid} from 'web/components/profile-grid'
|
||||
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {Title} from 'web/components/widgets/title'
|
||||
import {useGetter} from 'web/hooks/use-getter'
|
||||
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {api} from 'web/lib/api'
|
||||
import {useBookmarkedSearches} from "web/hooks/use-bookmarked-searches";
|
||||
import {orderProfiles} from "common/filters";
|
||||
import {useFilters} from "web/components/filters/use-filters";
|
||||
|
||||
export function ProfilesHome() {
|
||||
@@ -29,7 +28,7 @@ export function ProfilesHome() {
|
||||
locationFilterProps,
|
||||
} = useFilters(you ?? undefined);
|
||||
|
||||
const [profiles, setProfiles] = usePersistentInMemoryState<Profile[] | undefined>(undefined, 'profile-profiles');
|
||||
const [profiles, setProfiles] = usePersistentInMemoryState<Profile[] | undefined>(undefined, 'profiles');
|
||||
const {bookmarkedSearches, refreshBookmarkedSearches} = useBookmarkedSearches(user?.id)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
@@ -64,9 +63,12 @@ export function ProfilesHome() {
|
||||
});
|
||||
}, [filters]);
|
||||
|
||||
const {data: starredUserIds, refresh: refreshStars} = useGetter('star', user?.id, getStars);
|
||||
const compatibleProfiles = useCompatibleProfiles(user?.id);
|
||||
const displayProfiles = profiles && orderProfiles(profiles, starredUserIds);
|
||||
const {data: starredUsers, refresh: refreshStars} = useGetter('star', user?.id, getStars)
|
||||
const starredUserIds = starredUsers?.map((u) => u.id)
|
||||
|
||||
const compatibleProfiles = useCompatibleProfiles(user?.id)
|
||||
// const displayProfiles = profiles && orderProfiles(profiles, starredUserIds);
|
||||
const displayProfiles = profiles
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!profiles || isLoadingMore) return false;
|
||||
@@ -96,7 +98,8 @@ export function ProfilesHome() {
|
||||
<Title className="!mb-2 text-3xl">Profiles</Title>
|
||||
<Search
|
||||
youProfile={you}
|
||||
starredUserIds={starredUserIds ?? []}
|
||||
starredUsers={starredUsers ?? []}
|
||||
refreshStars={refreshStars}
|
||||
filters={filters}
|
||||
updateFilter={updateFilter}
|
||||
clearFilters={clearFilters}
|
||||
@@ -107,7 +110,7 @@ export function ProfilesHome() {
|
||||
refreshBookmarkedSearches={refreshBookmarkedSearches}
|
||||
/>
|
||||
{displayProfiles === undefined || compatibleProfiles === undefined ? (
|
||||
<LoadingIndicator/>
|
||||
<CompassLoadingIndicator/>
|
||||
) : (
|
||||
<ProfileGrid
|
||||
profiles={displayProfiles}
|
||||
|
||||
@@ -7,6 +7,11 @@ import {useUser} from "web/hooks/use-user";
|
||||
import {deleteBookmarkedSearch} from "web/lib/supabase/searches";
|
||||
import {formatFilters, locationType} from "common/searches";
|
||||
import {FilterFields} from "common/filters";
|
||||
import {api} from "web/lib/api";
|
||||
import {DisplayUser} from "common/api/user-types";
|
||||
import {useState} from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
|
||||
export function BookmarkSearchButton(props: {
|
||||
bookmarkedSearches: BookmarkedSearchesType[]
|
||||
@@ -26,7 +31,7 @@ export function BookmarkSearchButton(props: {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)} color="gray-outline" size={'xs'}>
|
||||
My Bookmarked Searches
|
||||
Saved Searches
|
||||
</Button>
|
||||
<ButtonModal
|
||||
open={open}
|
||||
@@ -57,32 +62,166 @@ function ButtonModal(props: {
|
||||
}}
|
||||
>
|
||||
<Col className={MODAL_CLASS}>
|
||||
<h3>Bookmarked Searches</h3>
|
||||
<p className='text-sm'>We'll notify you daily when new people match your searches below.</p>
|
||||
<Col
|
||||
className={
|
||||
'border-ink-300bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 text-sm shadow-sm'
|
||||
}
|
||||
>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
{(bookmarkedSearches || []).map((search) => (
|
||||
<li key={search.id}
|
||||
className="items-center justify-between gap-2 list-item marker:text-ink-500 marker:font-bold">
|
||||
{formatFilters(search.search_filters as Partial<FilterFields>, search.location as locationType)?.join(" • ")}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await deleteBookmarkedSearch(search.id)
|
||||
refreshBookmarkedSearches()
|
||||
}}
|
||||
className="inline-flex text-xl h-5 w-5 items-center justify-center rounded-full text-red-600 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<h3>Saved Searches</h3>
|
||||
{bookmarkedSearches?.length ? (<>
|
||||
<p>We'll notify you daily when new people match your searches below.</p>
|
||||
<Col
|
||||
className={
|
||||
'border-ink-300bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 shadow-sm'
|
||||
}
|
||||
>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
{(bookmarkedSearches || []).map((search) => (
|
||||
<li key={search.id}
|
||||
className="items-center justify-between gap-2 list-item marker:text-ink-500 marker:font-bold">
|
||||
{formatFilters(search.search_filters as Partial<FilterFields>, search.location as locationType)?.join(" • ")}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await deleteBookmarkedSearch(search.id)
|
||||
refreshBookmarkedSearches()
|
||||
}}
|
||||
className="inline-flex text-xl h-5 w-5 items-center justify-center rounded-full text-red-600 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
</Col>
|
||||
</Col>
|
||||
</>
|
||||
) :
|
||||
<p>You haven't saved any search. To save one, click on Get Notified and we'll notify you daily when new people
|
||||
match it.</p>}
|
||||
{/*<BookmarkSearchContent*/}
|
||||
{/* total={bookmarkedSearches.length}*/}
|
||||
{/* compatibilityQuestion={bookmarkedSearches[questionIndex]}*/}
|
||||
{/* user={user}*/}
|
||||
{/* onSubmit={() => {*/}
|
||||
{/* setOpen(false)*/}
|
||||
{/* }}*/}
|
||||
{/* isLastQuestion={questionIndex === bookmarkedSearches.length - 1}*/}
|
||||
{/* onNext={() => {*/}
|
||||
{/* if (questionIndex === bookmarkedSearches.length - 1) {*/}
|
||||
{/* setOpen(false)*/}
|
||||
{/* } else {*/}
|
||||
{/* setQuestionIndex(questionIndex + 1)*/}
|
||||
{/* }*/}
|
||||
{/* }}*/}
|
||||
{/*/>*/}
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookmarkStarButton(props: {
|
||||
starredUsers: DisplayUser[]
|
||||
refreshStars: () => void
|
||||
open: boolean
|
||||
setOpen: (checked: boolean) => void
|
||||
}) {
|
||||
const {
|
||||
starredUsers,
|
||||
refreshStars,
|
||||
open,
|
||||
setOpen,
|
||||
} = props
|
||||
const user = useUser()
|
||||
|
||||
if (!user) return null
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)} color="gray-outline" size={'xs'}>
|
||||
Saved Profiles
|
||||
</Button>
|
||||
<StarModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
starredUsers={starredUsers}
|
||||
refreshStars={refreshStars}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function StarModal(props: {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
user: User
|
||||
starredUsers: DisplayUser[]
|
||||
refreshStars: () => void
|
||||
}) {
|
||||
const {open, setOpen, starredUsers, refreshStars} = props
|
||||
// Track items being optimistically removed so we can hide them immediately
|
||||
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const visibleUsers = (starredUsers || []).filter((u) => !removingIds.has(u.id))
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
// onClose={() => {
|
||||
// refreshBookmarkedSearches()
|
||||
// }}
|
||||
>
|
||||
<Col className={MODAL_CLASS}>
|
||||
<h3>Saved Profiles</h3>
|
||||
{visibleUsers?.length ? (<>
|
||||
<p>Here are the profiles you saved:</p>
|
||||
<Col
|
||||
className={
|
||||
'border-ink-300bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 shadow-sm'
|
||||
}
|
||||
>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
{visibleUsers.map((user) => (
|
||||
<li key={user.id}
|
||||
className="items-center justify-between gap-2 list-item marker:text-ink-500 marker:font-bold">
|
||||
<a className={'customlink'}>
|
||||
{user.name} (<Link
|
||||
href={`/${user.username}`}
|
||||
// style={{color: "#2563eb", textDecoration: "none"}}
|
||||
>
|
||||
@{user.username}
|
||||
</Link>) {' '}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Optimistically remove the user from the list
|
||||
setRemovingIds((prev) => new Set(prev).add(user.id))
|
||||
// Fire the API call without blocking UI
|
||||
api('star-profile', {
|
||||
targetUserId: user.id,
|
||||
remove: true,
|
||||
})
|
||||
.then(() => {
|
||||
// Sync with server state
|
||||
refreshStars()
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Couldn't remove saved profile. Please try again.")
|
||||
// Revert optimistic removal on failure
|
||||
setRemovingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
})
|
||||
}}
|
||||
className="inline-flex text-xl h-5 w-5 items-center justify-center rounded-full text-red-600 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
</Col>
|
||||
</>
|
||||
) : <p>You haven't saved any profile. To save one, click on the star on their profile page.</p>}
|
||||
{/*<BookmarkSearchContent*/}
|
||||
{/* total={bookmarkedSearches.length}*/}
|
||||
{/* compatibilityQuestion={bookmarkedSearches[questionIndex]}*/}
|
||||
|
||||
@@ -20,7 +20,9 @@ export function UserHandles(props: { links: Socials; className?: string }) {
|
||||
Object.entries(links),
|
||||
([platform]) => -[...SITE_ORDER].reverse().indexOf(platform as Site)
|
||||
).map(([platform, label]) => {
|
||||
const renderedLabel: string = LABELS_TO_RENDER.includes(platform) ? PLATFORM_LABELS[platform as Site] : label
|
||||
let renderedLabel: string = LABELS_TO_RENDER.includes(platform) ? PLATFORM_LABELS[platform as Site] : label
|
||||
renderedLabel = renderedLabel?.replace(/\/+$/, '') // remove trailing slashes
|
||||
renderedLabel = renderedLabel?.replace(/^(https?:\/\/)?(www\.)?/, '') // remove protocol and www
|
||||
return {
|
||||
platform,
|
||||
label: renderedLabel,
|
||||
@@ -28,6 +30,10 @@ export function UserHandles(props: { links: Socials; className?: string }) {
|
||||
}
|
||||
})
|
||||
|
||||
if (display.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
className={clsx(
|
||||
|
||||
153
web/components/votes/vote-buttons.tsx
Normal file
153
web/components/votes/vote-buttons.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import clsx from 'clsx'
|
||||
import toast from 'react-hot-toast'
|
||||
import {api} from 'web/lib/api'
|
||||
import {useState, useEffect, useRef} from 'react'
|
||||
import {useUser} from "web/hooks/use-user";
|
||||
|
||||
export type VoteChoice = 'for' | 'abstain' | 'against'
|
||||
|
||||
function VoteButton(props: {
|
||||
color: string
|
||||
count: number
|
||||
title: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const {color, count, title, disabled, onClick} = props
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
className={clsx('px-2 xs:px-4 py-2 rounded-lg', color)}
|
||||
onClick={onClick}
|
||||
color={'gray-white'}
|
||||
>
|
||||
<div className="font-semibold mx-1 xs:mx-2">{count}</div>
|
||||
<div className="text-sm">{title}</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const priorities = [
|
||||
{label: 'Urgent', value: 3},
|
||||
{label: 'High', value: 2},
|
||||
{label: 'Medium', value: 1},
|
||||
{label: 'Low', value: 0},
|
||||
] as const
|
||||
|
||||
export function VoteButtons(props: {
|
||||
voteId: number
|
||||
counts: { for: number; abstain: number; against: number }
|
||||
onVoted?: () => void | Promise<void>
|
||||
className?: string
|
||||
}) {
|
||||
const user = useUser()
|
||||
const {voteId, counts, onVoted, className} = props
|
||||
const [loading, setLoading] = useState<VoteChoice | null>(null)
|
||||
const [showPriority, setShowPriority] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const disabled = loading !== null
|
||||
|
||||
// Close the dropdown when clicking outside or pressing Escape
|
||||
useEffect(() => {
|
||||
if (!showPriority) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null
|
||||
if (containerRef.current && target && !containerRef.current.contains(target)) {
|
||||
setShowPriority(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setShowPriority(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showPriority])
|
||||
|
||||
const sendVote = async (choice: VoteChoice, priority: number) => {
|
||||
try {
|
||||
setLoading(choice)
|
||||
if (!user) {
|
||||
toast.error('Please sign in to vote')
|
||||
return
|
||||
}
|
||||
await api('vote', {voteId, choice, priority})
|
||||
toast.success(`Voted ${choice}${choice === 'for' ? ` with priority ${priority}` : ''}`)
|
||||
await onVoted?.()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('Failed to vote — please try again')
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = async (choice: VoteChoice) => {
|
||||
if (choice === 'for') {
|
||||
// Toggle the priority dropdown
|
||||
setShowPriority((v) => !v)
|
||||
return
|
||||
}
|
||||
// Default priority 0 for non-for choices
|
||||
await sendVote(choice, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className={clsx('gap-2 xs:gap-4 mt-2 flex-wrap', className)}>
|
||||
<div className="relative" ref={containerRef}>
|
||||
<VoteButton
|
||||
color={clsx('bg-green-700 text-white hover:bg-green-500')}
|
||||
count={counts.for}
|
||||
title={'For'}
|
||||
disabled={disabled}
|
||||
onClick={() => handleVote('for')}
|
||||
/>
|
||||
{showPriority && (
|
||||
<div className={clsx(
|
||||
'absolute z-10 mt-2 w-40 rounded-md border border-ink-200 bg-canvas-50 shadow-lg',
|
||||
'dark:bg-ink-900'
|
||||
)}>
|
||||
{priorities.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2 text-sm hover:bg-ink-100 bg-canvas-50',
|
||||
'dark:hover:bg-canvas-100'
|
||||
)}
|
||||
onClick={async () => {
|
||||
setShowPriority(false)
|
||||
await sendVote('for', p.value)
|
||||
}}
|
||||
>
|
||||
{p.label} priority
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<VoteButton
|
||||
color={clsx('bg-yellow-700 text-white hover:bg-yellow-500')}
|
||||
count={counts.abstain}
|
||||
title={'Abstain'}
|
||||
disabled={disabled}
|
||||
onClick={() => handleVote('abstain')}
|
||||
/>
|
||||
<VoteButton
|
||||
color={clsx('bg-red-700 text-white hover:bg-red-500')}
|
||||
count={counts.against}
|
||||
title={'Against'}
|
||||
disabled={disabled}
|
||||
onClick={() => handleVote('against')}
|
||||
/>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
152
web/components/votes/vote-info.tsx
Normal file
152
web/components/votes/vote-info.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useGetter} from 'web/hooks/use-getter'
|
||||
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
|
||||
import {JSONContent} from "@tiptap/core";
|
||||
import {getVotes} from "web/lib/supabase/votes";
|
||||
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Button} from "web/components/buttons/button";
|
||||
import {Input} from "web/components/widgets/input";
|
||||
import {api} from "web/lib/api";
|
||||
import {Title} from "web/components/widgets/title";
|
||||
import toast from "react-hot-toast";
|
||||
import {Vote, VoteItem} from 'web/components/votes/vote-item'
|
||||
import Link from "next/link";
|
||||
import {formLink} from "common/constants";
|
||||
import { ShowMore } from "../widgets/show-more";
|
||||
import {ORDER_BY, Constants, OrderBy} from "common/votes/constants";
|
||||
|
||||
export function VoteComponent() {
|
||||
const user = useUser()
|
||||
const [orderBy, setOrderBy] = useState<OrderBy>('recent')
|
||||
|
||||
const {data: votes, refresh: refreshVotes} = useGetter(
|
||||
'votes',
|
||||
{orderBy},
|
||||
getVotes
|
||||
)
|
||||
|
||||
const [title, setTitle] = useState<string>('')
|
||||
const [editor, setEditor] = useState<any>(null)
|
||||
const [isAnonymous, setIsAnonymous] = useState<boolean>(false)
|
||||
|
||||
const hideButton = title.length == 0
|
||||
|
||||
return (
|
||||
<Col className="mx-2">
|
||||
<Row className="items-center justify-between flex-col xxs:flex-row mb-4 xxs:mb-0 gap-2">
|
||||
<Title className="text-3xl">Proposals</Title>
|
||||
<div className="flex items-center gap-2 text-sm justify-end">
|
||||
<label htmlFor="orderBy" className="text-gray-600">Order by:</label>
|
||||
<select
|
||||
id="orderBy"
|
||||
value={orderBy}
|
||||
onChange={(e) => setOrderBy(e.target.value as OrderBy)}
|
||||
className="rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50"
|
||||
>
|
||||
{ORDER_BY.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{Constants[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Row>
|
||||
<p className={'customlink'}>
|
||||
You can discuss any of those proposals through the <Link href={'/contact'}>contact form</Link>, the <Link href={formLink}>feedback form</Link>, or any of our <Link href={'/social'}>socials</Link>.
|
||||
</p>
|
||||
|
||||
{user && <Col>
|
||||
<ShowMore labelClosed="Add a new proposal" labelOpen="Hide">
|
||||
<Input
|
||||
value={title}
|
||||
placeholder={'Title'}
|
||||
className={'w-full mb-2'}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<VoteCreator
|
||||
onEditor={(e) => setEditor(e)}
|
||||
/>
|
||||
<Row className="mx-2 mb-2 items-center gap-2 text-sm text-gray-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="anonymous"
|
||||
checked={isAnonymous}
|
||||
onChange={(e) => setIsAnonymous(e.target.checked)}
|
||||
className="h-4 w-4 rounded-md border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="anonymous">Anonymous?</label>
|
||||
</Row>
|
||||
{!hideButton && (
|
||||
<Row className="right-1 justify-between gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
const data = {
|
||||
title: title,
|
||||
description: editor.getJSON() as JSONContent,
|
||||
isAnonymous: isAnonymous,
|
||||
};
|
||||
const newVote = await api('create-vote', data).catch(() => {
|
||||
toast.error('Failed to create vote — try again or contact us...')
|
||||
})
|
||||
if (!newVote) return
|
||||
setTitle('')
|
||||
editor.commands.clearContent()
|
||||
toast.success('Vote created')
|
||||
console.debug('Vote created', newVote)
|
||||
refreshVotes()
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</ShowMore>
|
||||
</Col>
|
||||
}
|
||||
|
||||
{votes && votes.length > 0 && <Col className={'mt-4'}>
|
||||
{votes.map((vote: Vote) => {
|
||||
return (
|
||||
<VoteItem key={vote.id} vote={vote} onVoted={refreshVotes}/>
|
||||
)
|
||||
})}
|
||||
</Col>}
|
||||
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
interface VoteCreatorProps {
|
||||
defaultValue?: any
|
||||
onBlur?: (editor: any) => void
|
||||
onEditor?: (editor: any) => void
|
||||
}
|
||||
|
||||
export function VoteCreator({defaultValue, onBlur, onEditor}: VoteCreatorProps) {
|
||||
const editor = useTextEditor({
|
||||
// extensions: [StarterKit],
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: defaultValue,
|
||||
placeholder: 'Please describe your proposal here',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onEditor?.(editor)
|
||||
}, [editor, onEditor])
|
||||
|
||||
return (
|
||||
<div className={'mb-2'}>
|
||||
{/*<p>Description</p>*/}
|
||||
<TextEditor
|
||||
editor={editor}
|
||||
onBlur={() => onBlur?.(editor)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
web/components/votes/vote-item.tsx
Normal file
53
web/components/votes/vote-item.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {Row as rowFor} from 'common/supabase/utils'
|
||||
import {Content} from 'web/components/widgets/editor'
|
||||
import {JSONContent} from '@tiptap/core'
|
||||
import {VoteButtons} from 'web/components/votes/vote-buttons'
|
||||
import {getVoteCreator} from "web/lib/supabase/votes";
|
||||
import {useEffect, useState} from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export type Vote = rowFor<'votes'> & {
|
||||
votes_for: number
|
||||
votes_against: number
|
||||
votes_abstain: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export function VoteItem(props: {
|
||||
vote: Vote
|
||||
onVoted?: () => void | Promise<void>
|
||||
}) {
|
||||
const {vote, onVoted} = props
|
||||
const [creator, setCreator] = useState<any>(null)
|
||||
useEffect(() => {
|
||||
getVoteCreator(vote.creator_id).then(setCreator)
|
||||
}, [vote.creator_id])
|
||||
// console.debug('creator', creator)
|
||||
return (
|
||||
<Col className={'mb-4 rounded-lg border border-ink-200 p-4'}>
|
||||
<Row className={'mb-2'}>
|
||||
<Col className={'flex-grow'}>
|
||||
<p className={'text-2xl'}>{vote.title}</p>
|
||||
<Col className='text-sm text-gray-500 italic'>
|
||||
<Content className="w-full" content={vote.description as JSONContent}/>
|
||||
</Col>
|
||||
<Row className={'gap-2 mt-2 items-center justify-between w-full customlink flex-wrap'}>
|
||||
{!!vote.priority ? <div>Priority: {vote.priority.toFixed(0)}%</div> : <p></p>}
|
||||
{!vote.is_anonymous && creator?.username && <Link href={`/${creator.username}`} className="customlink">{creator.username}</Link>}
|
||||
</Row>
|
||||
<VoteButtons
|
||||
voteId={vote.id}
|
||||
counts={{
|
||||
for: vote.votes_for,
|
||||
abstain: vote.votes_abstain,
|
||||
against: vote.votes_against,
|
||||
}}
|
||||
onVoted={onVoted}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const Card = forwardRef(function Card(
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-canvas-0 border-ink-300 cursor-pointer rounded-lg border transition-shadow hover:shadow-md focus:shadow-md',
|
||||
'bg-canvas-0 border-ink-300 rounded-lg border transition-shadow hover:shadow-md focus:shadow-md',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -28,6 +28,22 @@ function cumulativeFromCounts(counts: Record<string, number>, sortedDates: strin
|
||||
export default function ChartMembers() {
|
||||
const [data, setData] = useState<any[]>([])
|
||||
|
||||
const [chartHeight, setChartHeight] = useState<number>(400)
|
||||
|
||||
useEffect(() => {
|
||||
// Set responsive chart height: 300px on small widths, 400px otherwise
|
||||
function applyHeight() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const isSmall = window.innerWidth < 420
|
||||
setChartHeight(isSmall ? 320 : 400)
|
||||
}
|
||||
}
|
||||
|
||||
applyHeight()
|
||||
window.addEventListener('resize', applyHeight)
|
||||
return () => window.removeEventListener('resize', applyHeight)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [allProfiles, bioProfiles] = await Promise.all([
|
||||
@@ -92,49 +108,64 @@ export default function ChartMembers() {
|
||||
|
||||
// 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="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={{
|
||||
backgroundColor: "rgb(var(--color-canvas-100))",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
color: "rgb(var(--color-primary-900))",
|
||||
}}
|
||||
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="profilesWithBioCreations"
|
||||
name="With Bio"
|
||||
stroke="#9ca3af"
|
||||
strokeDasharray="4 2"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<LineChart data={data} margin={{top: 24, right: 16, bottom: 24, left: -20}}>
|
||||
<text
|
||||
x="50%"
|
||||
y="24"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
fontWeight: 600,
|
||||
fill: "rgb(var(--color-primary-900))",
|
||||
}}
|
||||
>
|
||||
Number of Members
|
||||
</text>
|
||||
{/*<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/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "rgb(var(--color-canvas-100))",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
color: "rgb(var(--color-primary-900))",
|
||||
}}
|
||||
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="profilesWithBioCreations"
|
||||
name="With Bio"
|
||||
stroke="#9ca3af"
|
||||
strokeDasharray="4 2"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import clsx from 'clsx'
|
||||
import {useEffect, useRef} from 'react'
|
||||
import FavIcon from "web/public/FavIcon";
|
||||
|
||||
export type SpinnerSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
function getSizeClass(size: SpinnerSize) {
|
||||
@@ -32,3 +35,55 @@ export function LoadingIndicator(props: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function CompassLoadingIndicator(props: {
|
||||
className?: string
|
||||
spinnerClassName?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}) {
|
||||
const {className, spinnerClassName} = props
|
||||
const compassRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = compassRef.current
|
||||
if (!el) return
|
||||
|
||||
let angle = 0
|
||||
let timeoutId: number
|
||||
|
||||
const randomTurn = () => {
|
||||
// Randomly choose direction and angle
|
||||
const direction = Math.random() > 0.5 ? 1 : -1
|
||||
const delta = Math.random() * 75 + 5
|
||||
angle = direction * delta
|
||||
|
||||
el.style.transform = `rotate(${angle}deg)`
|
||||
|
||||
// Random delay before next movement
|
||||
const delay = Math.random() * 400 + 400
|
||||
timeoutId = window.setTimeout(randomTurn, delay)
|
||||
}
|
||||
|
||||
randomTurn()
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center justify-center mt-8', className)}>
|
||||
<div
|
||||
ref={compassRef}
|
||||
className={clsx(
|
||||
'inline-block transition-transform duration-700 ease-in-out',
|
||||
// getSizeClass(size),
|
||||
spinnerClassName
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<FavIcon className="dark:invert w-20 h-20"/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function RadioToggleGroup(props: {
|
||||
|
||||
const length = orderedChoicesMap.length
|
||||
return (
|
||||
<Row className="text-ink-300 dark:text-ink-600 mb-6 items-center gap-3 text-sm">
|
||||
<Row className="text-ink-500 dark:text-ink-500 mb-6 items-center gap-3 text-sm">
|
||||
{orderedChoicesMap[0][0]}
|
||||
<RadioGroup
|
||||
className={clsx(
|
||||
|
||||
@@ -52,7 +52,7 @@ export function SearchableSelect(props: {
|
||||
|
||||
return (
|
||||
<Popover className={clsx('relative', parentClassName)}>
|
||||
{({ open, close }) => (
|
||||
{({ open: _open, close }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
ref={setReferenceElement}
|
||||
|
||||
38
web/components/widgets/show-more.tsx
Normal file
38
web/components/widgets/show-more.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {useState, ReactNode} from 'react'
|
||||
|
||||
interface ShowMoreProps {
|
||||
labelClosed?: string
|
||||
labelOpen?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ShowMore(props: ShowMoreProps) {
|
||||
const {labelClosed = 'Show more', labelOpen = 'Hide', children, className} = props
|
||||
const [showMoreInfo, setShowMoreInfo] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={`mt-2 mb-4 ${className ?? ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMoreInfo(!showMoreInfo)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"
|
||||
>
|
||||
{showMoreInfo ? labelOpen : labelClosed}
|
||||
<svg
|
||||
className={`w-4 h-4 ml-1 transition-transform ${showMoreInfo ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{showMoreInfo && (
|
||||
<div className="mt-2 p-3 rounded-md">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export const StarButton = (props: {
|
||||
>
|
||||
<StarIcon
|
||||
className={clsx(
|
||||
'h-10 w-10 transition-colors group-hover:fill-yellow-400/70',
|
||||
'h-8 w-8 transition-colors group-hover:fill-yellow-400/70',
|
||||
isStarred &&
|
||||
'fill-yellow-400 stroke-yellow-500 dark:stroke-yellow-600'
|
||||
)}
|
||||
@@ -63,7 +63,7 @@ export const StarButton = (props: {
|
||||
if (hideTooltip) return button
|
||||
|
||||
return (
|
||||
<Tooltip text={isStarred ? 'Remove star' : 'Add star'} noTap>
|
||||
<Tooltip text={isStarred ? 'Unsave Profile' : 'Save Profile'} noTap>
|
||||
{button}
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
52
web/components/widgets/stat-box.tsx
Normal file
52
web/components/widgets/stat-box.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import clsx from 'clsx'
|
||||
import { Card } from './card'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export type StatBoxProps = {
|
||||
// The main numeric/stat value to display large and centered
|
||||
value: string | number
|
||||
// The short label/caption shown below the value
|
||||
label?: ReactNode
|
||||
// Optional additional content (e.g., sublabel) shown below the label
|
||||
children?: ReactNode
|
||||
// Additional classes for the outer Card wrapper
|
||||
className?: string
|
||||
// Control the size of the number (default: xl)
|
||||
size?: 'lg' | 'xl' | '2xl' | '3xl'
|
||||
}
|
||||
|
||||
/**
|
||||
* A box component that displays a large number in the center with a few words below it.
|
||||
* It composes the shared Card style for visual consistency.
|
||||
*/
|
||||
export function StatBox(props: StatBoxProps) {
|
||||
const { value, label, children, className, size = '2xl' } = props
|
||||
|
||||
const sizeClass =
|
||||
size === '3xl'
|
||||
? 'text-6xl'
|
||||
: size === '2xl'
|
||||
? 'text-5xl'
|
||||
: size === 'xl'
|
||||
? 'text-4xl'
|
||||
: 'text-3xl'
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'flex h-full w-full flex-col items-center justify-center gap-2 p-6 text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={clsx('font-semibold leading-none tracking-tight', sizeClass)}>
|
||||
{value}
|
||||
</div>
|
||||
{label && (
|
||||
<div className="text-ink-700 text-sm">{label}</div>
|
||||
)}
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatBox
|
||||
@@ -1,14 +1,13 @@
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
import { VERIFIED_USERNAMES, MOD_IDS } from 'common/envs/constants'
|
||||
import { SparklesIcon } from '@heroicons/react/solid'
|
||||
import { Tooltip } from './tooltip'
|
||||
import { BadgeCheckIcon, ShieldCheckIcon } from '@heroicons/react/outline'
|
||||
import { Row } from '../layout/row'
|
||||
import { Avatar } from './avatar'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { linkClass } from './site-link'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import {MOD_IDS, VERIFIED_USERNAMES} from 'common/envs/constants'
|
||||
import {SparklesIcon} from '@heroicons/react/solid'
|
||||
import {Tooltip} from './tooltip'
|
||||
import {BadgeCheckIcon, ShieldCheckIcon} from '@heroicons/react/outline'
|
||||
import {Row} from '../layout/row'
|
||||
import {Avatar} from './avatar'
|
||||
import {DAY_MS} from 'common/util/time'
|
||||
import {linkClass} from './site-link'
|
||||
|
||||
export const isFresh = (createdTime: number) =>
|
||||
createdTime > Date.now() - DAY_MS * 14
|
||||
@@ -16,15 +15,13 @@ export const isFresh = (createdTime: number) =>
|
||||
export function shortenName(name: string) {
|
||||
const firstName = name.split(' ')[0]
|
||||
const maxLength = 10
|
||||
const shortName =
|
||||
firstName.length >= 3 && name.length > maxLength
|
||||
? firstName.length < maxLength
|
||||
? firstName
|
||||
: firstName.substring(0, maxLength - 3) + '...'
|
||||
: name.length > maxLength
|
||||
return firstName.length >= 3 && name.length > maxLength
|
||||
? firstName.length < maxLength
|
||||
? firstName
|
||||
: firstName.substring(0, maxLength - 3) + '...'
|
||||
: name.length > maxLength
|
||||
? name.substring(0, maxLength - 3) + '...'
|
||||
: name
|
||||
return shortName
|
||||
}
|
||||
|
||||
export function UserAvatarAndBadge(props: {
|
||||
@@ -32,8 +29,8 @@ export function UserAvatarAndBadge(props: {
|
||||
noLink?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { user, noLink, className } = props
|
||||
const { username, avatarUrl } = user
|
||||
const {user, noLink, className} = props
|
||||
const {username, avatarUrl} = user
|
||||
|
||||
return (
|
||||
<Row className={clsx('items-center gap-2', className)}>
|
||||
@@ -43,7 +40,7 @@ export function UserAvatarAndBadge(props: {
|
||||
size={'sm'}
|
||||
noLink={noLink}
|
||||
/>
|
||||
<UserLink user={user} noLink={noLink} />
|
||||
<UserLink user={user} noLink={noLink}/>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
@@ -57,7 +54,7 @@ export function UserLink(props: {
|
||||
hideBadge?: boolean
|
||||
}) {
|
||||
const {
|
||||
user: { id, name, username },
|
||||
user: {id, name, username},
|
||||
className,
|
||||
short,
|
||||
noLink,
|
||||
@@ -70,7 +67,7 @@ export function UserLink(props: {
|
||||
<>
|
||||
<span className="max-w-[200px] truncate">{shortName}</span>
|
||||
{!hideBadge && (
|
||||
<UserBadge userId={id} username={username} fresh={fresh} />
|
||||
<UserBadge userId={id} username={username} fresh={fresh}/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -98,13 +95,13 @@ export function UserLink(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function BotBadge() {
|
||||
return (
|
||||
<span className="bg-ink-100 text-ink-800 ml-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-medium">
|
||||
Bot
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// function BotBadge() {
|
||||
// return (
|
||||
// <span className="bg-ink-100 text-ink-800 ml-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-medium">
|
||||
// Bot
|
||||
// </span>
|
||||
// )
|
||||
// }
|
||||
|
||||
export function BannedBadge() {
|
||||
return (
|
||||
@@ -112,7 +109,8 @@ export function BannedBadge() {
|
||||
text="Can't create comments, messages, or questions"
|
||||
placement="bottom"
|
||||
>
|
||||
<span className="ml-1.5 rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-700 dark:text-yellow-100">
|
||||
<span
|
||||
className="ml-1.5 rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-700 dark:text-yellow-100">
|
||||
Banned
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -124,17 +122,17 @@ export function UserBadge(props: {
|
||||
username: string
|
||||
fresh?: boolean
|
||||
}) {
|
||||
const { userId, username, fresh } = props
|
||||
const {userId, username, fresh} = props
|
||||
const badges = []
|
||||
|
||||
if (MOD_IDS.includes(userId)) {
|
||||
badges.push(<ModBadge key="mod" />)
|
||||
badges.push(<ModBadge key="mod"/>)
|
||||
}
|
||||
if (VERIFIED_USERNAMES.includes(username)) {
|
||||
badges.push(<VerifiedBadge key="check" />)
|
||||
badges.push(<VerifiedBadge key="check"/>)
|
||||
}
|
||||
if (fresh) {
|
||||
badges.push(<FreshBadge key="fresh" />)
|
||||
badges.push(<FreshBadge key="fresh"/>)
|
||||
}
|
||||
return <>{badges}</>
|
||||
}
|
||||
@@ -155,7 +153,7 @@ function ModBadge() {
|
||||
function VerifiedBadge() {
|
||||
return (
|
||||
<Tooltip text="Verified" placement="right">
|
||||
<BadgeCheckIcon className="text-primary-700 h-4 w-4" aria-hidden />
|
||||
<BadgeCheckIcon className="text-primary-700 h-4 w-4" aria-hidden/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -164,51 +162,51 @@ function VerifiedBadge() {
|
||||
function FreshBadge() {
|
||||
return (
|
||||
<Tooltip text="I'm new here!" placement="right">
|
||||
<SparklesIcon className="h-4 w-4 text-green-500" aria-hidden="true" />
|
||||
<SparklesIcon className="h-4 w-4 text-green-500" aria-hidden="true"/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export const StackedUserNames = (props: {
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
username: string
|
||||
createdTime: number
|
||||
isBannedFromPosting?: boolean
|
||||
}
|
||||
followsYou?: boolean
|
||||
className?: string
|
||||
usernameClassName?: string
|
||||
}) => {
|
||||
const { user, followsYou, usernameClassName, className } = props
|
||||
return (
|
||||
<Col>
|
||||
<div className={'inline-flex flex-row items-center gap-1 pt-1'}>
|
||||
<span className={clsx('break-anywhere ', className)}>{user.name}</span>
|
||||
{
|
||||
<UserBadge
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
fresh={isFresh(user.createdTime)}
|
||||
/>
|
||||
}
|
||||
{user.isBannedFromPosting && <BannedBadge />}
|
||||
</div>
|
||||
<Row className={'flex-shrink flex-wrap gap-x-2'}>
|
||||
<span className={clsx('text-ink-400 text-sm', usernameClassName)}>
|
||||
@{user.username}{' '}
|
||||
</span>
|
||||
{followsYou && (
|
||||
<span
|
||||
className={
|
||||
'bg-ink-200 w-fit self-center rounded-md p-0.5 px-1 text-xs'
|
||||
}
|
||||
>
|
||||
Follows you
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
// export const StackedUserNames = (props: {
|
||||
// user: {
|
||||
// id: string
|
||||
// name: string
|
||||
// username: string
|
||||
// createdTime: number
|
||||
// isBannedFromPosting?: boolean
|
||||
// }
|
||||
// followsYou?: boolean
|
||||
// className?: string
|
||||
// usernameClassName?: string
|
||||
// }) => {
|
||||
// const {user, followsYou, usernameClassName, className} = props
|
||||
// return (
|
||||
// <Col>
|
||||
// <div className={'inline-flex flex-row items-center gap-1 pt-1'}>
|
||||
// <span className={clsx('break-anywhere ', className)}>{user.name}</span>
|
||||
// {
|
||||
// <UserBadge
|
||||
// userId={user.id}
|
||||
// username={user.username}
|
||||
// fresh={isFresh(user.createdTime)}
|
||||
// />
|
||||
// }
|
||||
// {user.isBannedFromPosting && <BannedBadge/>}
|
||||
// </div>
|
||||
// <Row className={'flex-shrink flex-wrap gap-x-2'}>
|
||||
// <span className={clsx('text-ink-400 text-sm', usernameClassName)}>
|
||||
// @{user.username}{' '}
|
||||
// </span>
|
||||
// {followsYou && (
|
||||
// <span
|
||||
// className={
|
||||
// 'bg-ink-200 w-fit self-center rounded-md p-0.5 px-1 text-xs'
|
||||
// }
|
||||
// >
|
||||
// Follows you
|
||||
// </span>
|
||||
// )}
|
||||
// </Row>
|
||||
// </Col>
|
||||
// )
|
||||
// }
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import {useEffect} from 'react'
|
||||
|
||||
import { APIParams, APIPath, APIResponse } from 'common/api/schema'
|
||||
import { usePersistentInMemoryState } from './use-persistent-in-memory-state'
|
||||
import { APIError, api } from 'web/lib/api'
|
||||
import { useEvent } from './use-event'
|
||||
import {APIParams, APIPath, APIResponse} from 'common/api/schema'
|
||||
import {usePersistentInMemoryState} from './use-persistent-in-memory-state'
|
||||
import {api} from 'web/lib/api'
|
||||
import {useEvent} from './use-event'
|
||||
import {APIError} from "common/api/utils";
|
||||
|
||||
const promiseCache: Record<string, Promise<any> | undefined> = {}
|
||||
|
||||
@@ -45,7 +46,7 @@ export const useAPIGetter = <P extends APIPath>(
|
||||
refresh()
|
||||
}, [propsStringToTriggerRefresh])
|
||||
|
||||
return { data, error, refresh }
|
||||
return {data, error, refresh}
|
||||
}
|
||||
|
||||
function deepCopyWithoutKeys(obj: any, keysToRemove: string[]): any {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { unauthedApi } from 'common/util/api'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'
|
||||
import {useEffect, useRef} from 'react'
|
||||
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
|
||||
import {api} from "web/lib/api";
|
||||
|
||||
export function useNearbyCities(
|
||||
referenceCityId: string | null | undefined,
|
||||
@@ -15,7 +15,7 @@ export function useNearbyCities(
|
||||
searchCount.current++
|
||||
const thisSearchCount = searchCount.current
|
||||
if (referenceCityId) {
|
||||
unauthedApi('search-near-city', {
|
||||
api('search-near-city', {
|
||||
cityId: referenceCityId,
|
||||
radius,
|
||||
}).then((result) => {
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useProfile } from 'web/hooks/use-profile'
|
||||
import { useIsAuthorized } from 'web/hooks/use-user'
|
||||
import { run } from 'common/supabase/utils'
|
||||
import { db } from 'web/lib/supabase/db'
|
||||
import {useEffect} from 'react'
|
||||
import {useProfile} from 'web/hooks/use-profile'
|
||||
import {useIsAuthorized} from 'web/hooks/use-user'
|
||||
import {api} from 'web/lib/api'
|
||||
|
||||
export const useOnline = () => {
|
||||
const profile = useProfile()
|
||||
const isAuthed = useIsAuthorized()
|
||||
useEffect(() => {
|
||||
if (!profile || !isAuthed) return
|
||||
run(
|
||||
db
|
||||
.from('profiles')
|
||||
.update({ last_online_time: new Date().toISOString() })
|
||||
.eq('id', profile.id)
|
||||
)
|
||||
}, [])
|
||||
void (async () => {
|
||||
const date = new Date().toISOString()
|
||||
// const result = await run(
|
||||
// db
|
||||
// .from('profiles')
|
||||
// .update({ last_online_time: date })
|
||||
// .eq('id', profile.id)
|
||||
// )
|
||||
api('set-last-online-time')
|
||||
// console.log('set last online time for', profile.id, date)
|
||||
})()
|
||||
}, [profile?.id, isAuthed])
|
||||
}
|
||||
|
||||
28
web/hooks/use-user-activity.ts
Normal file
28
web/hooks/use-user-activity.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import { db } from 'web/lib/supabase/db'
|
||||
import { run } from 'common/supabase/utils'
|
||||
import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state'
|
||||
import { UserActivity } from 'common/user'
|
||||
|
||||
export function useUserActivity(userId: string | undefined) {
|
||||
const [userActivity, setUserActivity] = usePersistentInMemoryState<
|
||||
UserActivity | undefined
|
||||
>(undefined, `user-activity-${userId ?? 'none'}`)
|
||||
|
||||
const refresh = async () => {
|
||||
if (!userId) return
|
||||
const { data } = await run(
|
||||
db.from('user_activity')
|
||||
.select('*')
|
||||
.eq('user_id', userId).limit(1).single()
|
||||
)
|
||||
if (data) setUserActivity(data as unknown as UserActivity)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => {})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId])
|
||||
|
||||
return { data: userActivity, refresh }
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { API, APIParams, APIPath } from 'common/api/schema'
|
||||
import { typedAPICall } from 'common/util/api'
|
||||
import { sleep } from 'common/util/time'
|
||||
import { auth } from './firebase/users'
|
||||
export { APIError } from 'common/api/utils'
|
||||
import {API, APIParams, APIPath} from 'common/api/schema'
|
||||
import {typedAPICall} from 'common/util/api'
|
||||
import {sleep} from 'common/util/time'
|
||||
import {auth} from './firebase/users'
|
||||
|
||||
export async function api<P extends APIPath>(
|
||||
path: P,
|
||||
@@ -14,11 +13,12 @@ export async function api<P extends APIPath>(
|
||||
while (!auth.currentUser) {
|
||||
i++
|
||||
await sleep(i * 10)
|
||||
if (i > 10) {
|
||||
console.error('User did not load after 10 iterations')
|
||||
if (i > 300) {
|
||||
console.error('User did not load after 300 iterations')
|
||||
break
|
||||
}
|
||||
}
|
||||
console.debug('User loaded after', i, 'iterations')
|
||||
}
|
||||
|
||||
return typedAPICall(path, params, auth.currentUser)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { run } from 'common/supabase/utils'
|
||||
import { db } from 'web/lib/supabase/db'
|
||||
import {DisplayUser} from "common/api/user-types";
|
||||
|
||||
export const getStars = async (creatorId: string) => {
|
||||
const { data } = await run(
|
||||
@@ -12,5 +13,13 @@ export const getStars = async (creatorId: string) => {
|
||||
|
||||
if (!data) return []
|
||||
|
||||
return data.map((d) => d.target_id as string)
|
||||
const ids = data.map((d) => d.target_id as string)
|
||||
const {data: users} = await run(
|
||||
db
|
||||
.from('users')
|
||||
.select(`id, name, username`)
|
||||
.in('id', ids)
|
||||
)
|
||||
|
||||
return users as unknown as DisplayUser[]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {db} from './db'
|
||||
import {run} from 'common/supabase/utils'
|
||||
import {APIError, api} from 'web/lib/api'
|
||||
import {api} from 'web/lib/api'
|
||||
import {unauthedApi} from 'common/util/api'
|
||||
import type {DisplayUser} from 'common/api/user-types'
|
||||
import {MIN_BIO_LENGTH} from "common/constants";
|
||||
import {MONTH_MS} from "common/util/time";
|
||||
import {APIError} from "common/api/utils";
|
||||
|
||||
export type {DisplayUser}
|
||||
|
||||
@@ -43,7 +45,7 @@ export async function getFullUserById(id: string) {
|
||||
}
|
||||
|
||||
export async function searchUsers(prompt: string, limit: number) {
|
||||
return unauthedApi('search-users', {term: prompt, limit: limit})
|
||||
return api('search-users', {term: prompt, limit: limit})
|
||||
}
|
||||
|
||||
export async function getDisplayUsers(userIds: string[]) {
|
||||
@@ -75,4 +77,30 @@ export async function getProfilesWithBioCreations() {
|
||||
.order('created_time')
|
||||
)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCount(table: string) {
|
||||
if (table == 'private_user_messages') {
|
||||
const result = await api('get-messages-count')
|
||||
return result.count
|
||||
}
|
||||
if (table == 'active_members') {
|
||||
const {count} = await run(
|
||||
db
|
||||
.from('user_activity')
|
||||
.select('*', {count: 'exact', head: true})
|
||||
.gt('last_online_time', new Date(Date.now() - MONTH_MS).toISOString()) // last month
|
||||
)
|
||||
return count;
|
||||
}
|
||||
const {count} = await run(
|
||||
db
|
||||
.from(table)
|
||||
.select('*', {count: 'exact', head: true})
|
||||
)
|
||||
return count;
|
||||
}
|
||||
|
||||
// export async function getNumberProfiles() {
|
||||
// return await getCount('profiles');
|
||||
// }
|
||||
|
||||
26
web/lib/supabase/votes.ts
Normal file
26
web/lib/supabase/votes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {run} from 'common/supabase/utils'
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
|
||||
import {OrderBy} from "common/votes/constants";
|
||||
|
||||
export const getVotes = async (params: { orderBy: OrderBy }) => {
|
||||
const { orderBy } = params
|
||||
const {data, error} = await db.rpc('get_votes_with_results' as any, {
|
||||
order_by: orderBy,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const getVoteCreator = async (creatorId: string) => {
|
||||
const {data} = await run(
|
||||
db
|
||||
.from('users')
|
||||
.select(`id, name, username`)
|
||||
.eq('id', creatorId)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
return data[0]
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import { getProfileRow } from 'common/love/profile'
|
||||
|
||||
export const signupThenMaybeRedirectToSignup = async () => {
|
||||
const creds = await firebaseLogin()
|
||||
await Router.push('/')
|
||||
const userId = creds?.user.uid
|
||||
if (userId) {
|
||||
const profile = await getProfileRow(userId, db)
|
||||
if (!profile) {
|
||||
if (profile) {
|
||||
await Router.push('/')
|
||||
} else {
|
||||
await Router.push('/signup')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export function fromNow(time: number) {
|
||||
export function fromNow(time: number | string | Date) {
|
||||
return dayjs(time).fromNow()
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ module.exports = {
|
||||
return config
|
||||
},
|
||||
async redirects() {
|
||||
return []
|
||||
return [
|
||||
{ source: '/discord', destination: 'https://discord.gg/8Vd7jzqjun', permanent: false },
|
||||
{ source: '/patreon', destination: 'https://patreon.com/CompassMeet', permanent: false },
|
||||
{ source: '/paypal', destination: 'https://www.paypal.com/paypalme/CompassConnections', permanent: false },
|
||||
{ source: '/github', destination: "https://github.com/CompassConnections/Compass", permanent: false },
|
||||
{ source: '/charts', destination: "/stats", permanent: true },
|
||||
];
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import {useState} from 'react'
|
||||
import Router from 'next/router'
|
||||
import Head from 'next/head'
|
||||
import {useRouter} from 'next/router'
|
||||
import Head from 'next/head'
|
||||
import {LovePage} from 'web/components/love-page'
|
||||
import {useProfileByUser} from 'web/hooks/use-profile'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {SEO} from 'web/components/SEO'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useTracking} from 'web/hooks/use-tracking'
|
||||
@@ -19,6 +16,7 @@ import {ProfileInfo} from 'web/components/profile/profile-info'
|
||||
import {User} from 'common/user'
|
||||
import {getUserForStaticProps} from 'common/supabase/users'
|
||||
import {type GetStaticProps} from 'next'
|
||||
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
|
||||
|
||||
export const getStaticProps: GetStaticProps<
|
||||
UserPageProps,
|
||||
@@ -124,7 +122,7 @@ function UserPageInner(props: ActiveUserPageProps) {
|
||||
const fromSignup = query.fromSignup === 'true'
|
||||
|
||||
const currentUser = useUser()
|
||||
const isCurrentUser = currentUser?.id === user?.id
|
||||
// const isCurrentUser = currentUser?.id === user?.id
|
||||
|
||||
useSaveReferral(currentUser, {defaultReferrerUsername: username})
|
||||
useTracking('view love profile', {username: user?.username})
|
||||
@@ -166,24 +164,8 @@ function UserPageInner(props: ActiveUserPageProps) {
|
||||
refreshProfile={refreshProfile}
|
||||
fromSignup={fromSignup}
|
||||
/>
|
||||
) : isCurrentUser ? (
|
||||
<Col className={'mt-4 w-full items-center'}>
|
||||
<Row>
|
||||
<Button onClick={() => Router.push('/signup')}>
|
||||
Create a profile
|
||||
</Button>
|
||||
</Row>
|
||||
</Col>
|
||||
) : (
|
||||
<Col className="bg-canvas-0 rounded p-4 ">
|
||||
<div>{user.name} hasn't created a profile yet.</div>
|
||||
<Button
|
||||
className="mt-4 self-start"
|
||||
onClick={() => Router.push('/')}
|
||||
>
|
||||
See more profiles
|
||||
</Button>
|
||||
</Col>
|
||||
<CompassLoadingIndicator/>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function About() {
|
||||
<AboutBlock
|
||||
title="Democratic"
|
||||
text={<span
|
||||
className="customlink">Governed by the community, while ensuring no drift through our <Link
|
||||
className="customlink">Governed and <Link href="/vote">voted</Link> by the community, while ensuring no drift through our <Link
|
||||
href="/constitution">constitution</Link>.</span>}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import {LovePage} from "web/components/love-page";
|
||||
import ChartMembers from "web/components/widgets/charts";
|
||||
|
||||
export default function Charts() {
|
||||
return (
|
||||
<LovePage
|
||||
trackPageView={'charts'}
|
||||
>
|
||||
<h1 className="text-3xl font-semibold text-center mb-6">Community Growth over Time</h1>
|
||||
<ChartMembers/>
|
||||
</LovePage>
|
||||
);
|
||||
}
|
||||
20
web/pages/contact.tsx
Normal file
20
web/pages/contact.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import {LovePage} from 'web/components/love-page'
|
||||
import {SEO} from 'web/components/SEO'
|
||||
import {ContactComponent} from "web/components/contact";
|
||||
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<LovePage
|
||||
trackPageView={'vote page'}
|
||||
className={'relative p-2 sm:pt-0'}
|
||||
>
|
||||
<SEO
|
||||
title={`Contact`}
|
||||
description={'Contact us'}
|
||||
url={`/contact`}
|
||||
/>
|
||||
<ContactComponent/>
|
||||
</LovePage>
|
||||
)
|
||||
}
|
||||
10
web/pages/loading.tsx
Normal file
10
web/pages/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
|
||||
import {LovePage} from "web/components/love-page";
|
||||
|
||||
export default function Loading() {
|
||||
return <LovePage trackPageView={'loading'}>
|
||||
<CompassLoadingIndicator/>
|
||||
</LovePage>;
|
||||
}
|
||||
@@ -1,65 +1,53 @@
|
||||
import { LovePage } from 'web/components/love-page'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
usePrivateMessages,
|
||||
useSortedPrivateMessageMemberships,
|
||||
} from 'web/hooks/use-private-messages'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { User } from 'common/user'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { uniq } from 'lodash'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useTextEditor } from 'web/components/widgets/editor'
|
||||
import { api } from 'web/lib/api'
|
||||
import {
|
||||
ChatMessageItem,
|
||||
SystemChatMessageItem,
|
||||
} from 'web/components/chat/chat-message'
|
||||
import { CommentInputTextArea } from 'web/components/comments/comment-input'
|
||||
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
|
||||
import { DAY_MS, YEAR_MS } from 'common/util/time'
|
||||
import { useUsersInStore } from 'web/hooks/use-user-supabase'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import {LovePage} from 'web/components/love-page'
|
||||
import {useRouter} from 'next/router'
|
||||
import {usePrivateMessages, useSortedPrivateMessageMemberships,} from 'web/hooks/use-private-messages'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {User} from 'common/user'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {track} from 'web/lib/service/analytics'
|
||||
import {firebaseLogin} from 'web/lib/firebase/users'
|
||||
import {uniq} from 'lodash'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useTextEditor} from 'web/components/widgets/editor'
|
||||
import {api} from 'web/lib/api'
|
||||
import {ChatMessageItem, SystemChatMessageItem,} from 'web/components/chat/chat-message'
|
||||
import {CommentInputTextArea} from 'web/components/comments/comment-input'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {DAY_MS, YEAR_MS} from 'common/util/time'
|
||||
import {useUsersInStore} from 'web/hooks/use-user-supabase'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import clsx from 'clsx'
|
||||
import { useRedirectIfSignedOut } from 'web/hooks/use-redirect-if-signed-out'
|
||||
import { MultipleOrSingleAvatars } from 'web/components/multiple-or-single-avatars'
|
||||
import { Modal, MODAL_CLASS } from 'web/components/layout/modal'
|
||||
import {
|
||||
BannedBadge,
|
||||
UserAvatarAndBadge,
|
||||
} from 'web/components/widgets/user-link'
|
||||
import {useRedirectIfSignedOut} from 'web/hooks/use-redirect-if-signed-out'
|
||||
import {MultipleOrSingleAvatars} from 'web/components/multiple-or-single-avatars'
|
||||
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
|
||||
import {BannedBadge, UserAvatarAndBadge,} from 'web/components/widgets/user-link'
|
||||
import DropdownMenu from 'web/components/comments/dropdown-menu'
|
||||
import { DotsVerticalIcon } from '@heroicons/react/solid'
|
||||
import { FaUserFriends, FaUserMinus } from 'react-icons/fa'
|
||||
import { buildArray, filterDefined } from 'common/util/array'
|
||||
import { GiSpeakerOff } from 'react-icons/gi'
|
||||
import {DotsVerticalIcon} from '@heroicons/react/solid'
|
||||
import {FaUserFriends, FaUserMinus} from 'react-icons/fa'
|
||||
import {buildArray, filterDefined} from 'common/util/array'
|
||||
import {GiSpeakerOff} from 'react-icons/gi'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||
import {
|
||||
useGroupedMessages,
|
||||
usePaginatedScrollingMessages,
|
||||
} from 'web/lib/supabase/chat-messages'
|
||||
import { PrivateMessageChannel } from 'common/supabase/private-messages'
|
||||
import { ChatMessage } from 'common/chat-message'
|
||||
import { BackButton } from 'web/components/back-button'
|
||||
import {useIsMobile} from 'web/hooks/use-is-mobile'
|
||||
import {useGroupedMessages, usePaginatedScrollingMessages,} from 'web/lib/supabase/chat-messages'
|
||||
import {PrivateMessageChannel} from 'common/supabase/private-messages'
|
||||
import {ChatMessage} from 'common/chat-message'
|
||||
import {BackButton} from 'web/components/back-button'
|
||||
|
||||
export default function PrivateMessagesPage() {
|
||||
const router = useRouter()
|
||||
const { channelId: channelIdString } = router.query as { channelId: string }
|
||||
const {channelId: channelIdString} = router.query as { channelId: string }
|
||||
const channelId = router.isReady ? parseInt(channelIdString) : undefined
|
||||
const user = useUser()
|
||||
if (user === null) {
|
||||
router.replace(`/signin?returnTo=${encodeURIComponent('/messages')}`)
|
||||
return <LoadingIndicator />
|
||||
return <CompassLoadingIndicator/>
|
||||
}
|
||||
return (
|
||||
<LovePage trackPageView={'private messages page'}>
|
||||
{router.isReady && channelId && user ? (
|
||||
<PrivateMessagesContent user={user} channelId={channelId} />
|
||||
<PrivateMessagesContent user={user} channelId={channelId}/>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
<CompassLoadingIndicator/>
|
||||
)}
|
||||
</LovePage>
|
||||
)
|
||||
@@ -71,13 +59,13 @@ export function PrivateMessagesContent(props: {
|
||||
}) {
|
||||
useRedirectIfSignedOut()
|
||||
|
||||
const { channelId, user } = props
|
||||
const {channelId, user} = props
|
||||
const channelMembership = useSortedPrivateMessageMemberships(
|
||||
user.id,
|
||||
1,
|
||||
channelId
|
||||
)
|
||||
const { channels, memberIdsByChannelId } = channelMembership
|
||||
const {channels, memberIdsByChannelId} = channelMembership
|
||||
const thisChannel = channels?.find((c) => c.channel_id == channelId)
|
||||
const loaded = channels !== undefined && channelId
|
||||
const memberIds = thisChannel
|
||||
@@ -87,9 +75,9 @@ export function PrivateMessagesContent(props: {
|
||||
return (
|
||||
<>
|
||||
{user && loaded && thisChannel && memberIds ? (
|
||||
<PrivateChat channel={thisChannel} user={user} memberIds={memberIds} />
|
||||
<PrivateChat channel={thisChannel} user={user} memberIds={memberIds}/>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
<CompassLoadingIndicator/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -100,7 +88,7 @@ export const PrivateChat = (props: {
|
||||
channel: PrivateMessageChannel
|
||||
memberIds: string[]
|
||||
}) => {
|
||||
const { user, channel, memberIds } = props
|
||||
const {user, channel, memberIds} = props
|
||||
const channelId = channel.channel_id
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||
const isMobile = useIsMobile()
|
||||
@@ -132,7 +120,7 @@ export const PrivateChat = (props: {
|
||||
)
|
||||
const router = useRouter()
|
||||
|
||||
const { topVisibleRef, showMessages, messages, innerDiv, outerDiv } =
|
||||
const {topVisibleRef, showMessages, messages, innerDiv, outerDiv} =
|
||||
usePaginatedScrollingMessages(
|
||||
realtimeMessages?.map(
|
||||
(m) =>
|
||||
@@ -193,7 +181,7 @@ export const PrivateChat = (props: {
|
||||
'border-ink-200 bg-canvas-50 h-14 items-center gap-1 border-b'
|
||||
}
|
||||
>
|
||||
<BackButton className="self-stretch" />
|
||||
<BackButton className="self-stretch"/>
|
||||
<MultipleOrSingleAvatars
|
||||
size="sm"
|
||||
spacing={0.5}
|
||||
@@ -219,22 +207,22 @@ export const PrivateChat = (props: {
|
||||
)}
|
||||
|
||||
{members?.length == 1 && members[0].isBannedFromPosting && (
|
||||
<BannedBadge />
|
||||
<BannedBadge/>
|
||||
)}
|
||||
<DropdownMenu
|
||||
className={'ml-auto [&_button]:p-4'}
|
||||
menuWidth={'w-44'}
|
||||
icon={<DotsVerticalIcon className="h-5 w-5" />}
|
||||
icon={<DotsVerticalIcon className="h-5 w-5"/>}
|
||||
items={buildArray(
|
||||
{
|
||||
icon: <FaUserFriends className={'h-5 w-5'} />,
|
||||
icon: <FaUserFriends className={'h-5 w-5'}/>,
|
||||
name: 'See members',
|
||||
onClick: () => {
|
||||
setShowUsers(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <GiSpeakerOff className="h-5 w-5" />,
|
||||
icon: <GiSpeakerOff className="h-5 w-5"/>,
|
||||
name: 'Mute 1 day',
|
||||
onClick: async () => {
|
||||
await toast.promise(
|
||||
@@ -251,7 +239,7 @@ export const PrivateChat = (props: {
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <GiSpeakerOff className="h-5 w-5" />,
|
||||
icon: <GiSpeakerOff className="h-5 w-5"/>,
|
||||
name: 'Mute forever',
|
||||
onClick: async () => {
|
||||
await toast.promise(
|
||||
@@ -268,7 +256,7 @@ export const PrivateChat = (props: {
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <FaUserMinus className="h-5 w-5" />,
|
||||
icon: <FaUserMinus className="h-5 w-5"/>,
|
||||
name: 'Leave chat',
|
||||
onClick: async () => {
|
||||
await api('leave-private-user-message-channel', {
|
||||
@@ -287,7 +275,7 @@ export const PrivateChat = (props: {
|
||||
key={user.id}
|
||||
className={'w-full items-center justify-start gap-2'}
|
||||
>
|
||||
<UserAvatarAndBadge user={user} />
|
||||
<UserAvatarAndBadge user={user}/>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
@@ -311,13 +299,13 @@ export const PrivateChat = (props: {
|
||||
}}
|
||||
>
|
||||
{realtimeMessages === undefined ? (
|
||||
<LoadingIndicator />
|
||||
<CompassLoadingIndicator/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={'absolute h-1 '}
|
||||
ref={topVisibleRef}
|
||||
style={{ top: heightFromTop }}
|
||||
style={{top: heightFromTop}}
|
||||
/>
|
||||
{groupedMessages.map((messages, i) => {
|
||||
const firstMessage = messages[0]
|
||||
@@ -383,5 +371,5 @@ export const PrivateChat = (props: {
|
||||
}
|
||||
|
||||
const setAsSeen = async (channelId: number) => {
|
||||
return api('set-channel-seen-time', { channelId })
|
||||
return api('set-channel-seen-time', {channelId})
|
||||
}
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
import {
|
||||
NOTIFICATIONS_PER_PAGE,
|
||||
NOTIFICATION_TYPES_TO_SELECT,
|
||||
type Notification,
|
||||
} from 'common/notifications'
|
||||
import { PrivateUser, type User } from 'common/src/user'
|
||||
import {type Notification, NOTIFICATION_TYPES_TO_SELECT, NOTIFICATIONS_PER_PAGE,} from 'common/notifications'
|
||||
import {PrivateUser, type User} from 'common/src/user'
|
||||
import {
|
||||
notification_destination_types,
|
||||
notification_preference,
|
||||
notification_preferences,
|
||||
} from 'common/user-notification-preferences'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { NoSEO } from 'web/components/NoSEO'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { UncontrolledTabs } from 'web/components/layout/tabs'
|
||||
import { LovePage } from 'web/components/love-page'
|
||||
import { NotificationItem } from 'web/components/notification-items'
|
||||
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
|
||||
import { Pagination } from 'web/components/widgets/pagination'
|
||||
import { Title } from 'web/components/widgets/title'
|
||||
import { useGroupedNotifications } from 'web/hooks/use-notifications'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { api } from 'web/lib/api'
|
||||
import { MultiSelectAnswers } from 'web/components/answers/answer-compatibility-question-content'
|
||||
import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state'
|
||||
import { debounce } from 'lodash'
|
||||
import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {NoSEO} from 'web/components/NoSEO'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {UncontrolledTabs} from 'web/components/layout/tabs'
|
||||
import {LovePage} from 'web/components/love-page'
|
||||
import {NotificationItem} from 'web/components/notification-items'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {Pagination} from 'web/components/widgets/pagination'
|
||||
import {Title} from 'web/components/widgets/title'
|
||||
import {useGroupedNotifications} from 'web/hooks/use-notifications'
|
||||
import {usePrivateUser, useUser} from 'web/hooks/use-user'
|
||||
import {api} from 'web/lib/api'
|
||||
import {MultiSelectAnswers} from 'web/components/answers/answer-compatibility-question-content'
|
||||
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
|
||||
import {debounce} from 'lodash'
|
||||
import {useRedirectIfSignedOut} from "web/hooks/use-redirect-if-signed-out";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
useRedirectIfSignedOut()
|
||||
return (
|
||||
<LovePage trackPageView={'notifications page'}>
|
||||
<NoSEO />
|
||||
<NoSEO/>
|
||||
<Title>Updates</Title>
|
||||
<UncontrolledTabs
|
||||
tabs={[
|
||||
{ title: 'Notifications', content: <NotificationsContent /> },
|
||||
{ title: 'Settings', content: <NotificationSettings /> },
|
||||
{title: 'Notifications', content: <NotificationsContent/>},
|
||||
{title: 'Settings', content: <NotificationSettings/>},
|
||||
]}
|
||||
trackingName={'notifications page'}
|
||||
/>
|
||||
@@ -43,15 +41,15 @@ export default function NotificationsPage() {
|
||||
|
||||
const NotificationsContent = () => {
|
||||
const user = useUser()
|
||||
if (!user) return <LoadingIndicator />
|
||||
return <LoadedNotificationsContent user={user} />
|
||||
if (!user) return <CompassLoadingIndicator/>
|
||||
return <LoadedNotificationsContent user={user}/>
|
||||
}
|
||||
|
||||
function LoadedNotificationsContent(props: { user: User }) {
|
||||
const { user } = props
|
||||
const {user} = props
|
||||
const privateUser = usePrivateUser()
|
||||
|
||||
const { groupedNotifications, mostRecentNotification } =
|
||||
const {groupedNotifications, mostRecentNotification} =
|
||||
useGroupedNotifications(user, NOTIFICATION_TYPES_TO_SELECT)
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
@@ -65,7 +63,7 @@ function LoadedNotificationsContent(props: { user: User }) {
|
||||
// Mark all notifications as seen. Rerun as new notifications come in.
|
||||
useEffect(() => {
|
||||
if (!privateUser) return
|
||||
api('mark-all-notifs-read', { seen: true })
|
||||
api('mark-all-notifs-read', {seen: true})
|
||||
groupedNotifications
|
||||
?.map((ng) => ng.notifications)
|
||||
.flat()
|
||||
@@ -79,7 +77,7 @@ function LoadedNotificationsContent(props: { user: User }) {
|
||||
<Col className={'min-h-[100vh] gap-0 text-sm'}>
|
||||
{groupedNotifications === undefined ||
|
||||
paginatedGroupedNotifications === undefined ? (
|
||||
<LoadingIndicator />
|
||||
<CompassLoadingIndicator/>
|
||||
) : paginatedGroupedNotifications.length === 0 ? (
|
||||
<div className={'mt-2'}>You don't have any notifications, yet.</div>
|
||||
) : (
|
||||
@@ -107,15 +105,15 @@ function RenderNotificationGroups(props: {
|
||||
page: number
|
||||
setPage: (page: number) => void
|
||||
}) {
|
||||
const { notificationGroups, page, setPage, totalItems } = props
|
||||
const {notificationGroups, page, setPage, totalItems} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{notificationGroups.map((notification) => {
|
||||
return notification.notifications.map((notification: Notification) => (
|
||||
<Fragment key={notification.id}>
|
||||
<NotificationItem notification={notification} />
|
||||
<div className="bg-ink-300 mx-2 box-border h-[1.5px]" />
|
||||
<NotificationItem notification={notification}/>
|
||||
<div className="bg-ink-300 mx-2 box-border h-[1.5px]"/>
|
||||
</Fragment>
|
||||
))
|
||||
})}
|
||||
@@ -136,11 +134,11 @@ function RenderNotificationGroups(props: {
|
||||
const NotificationSettings = () => {
|
||||
const privateUser = usePrivateUser()
|
||||
if (!privateUser) return null
|
||||
return <LoadedNotificationSettings privateUser={privateUser} />
|
||||
return <LoadedNotificationSettings privateUser={privateUser}/>
|
||||
}
|
||||
|
||||
const LoadedNotificationSettings = (props: { privateUser: PrivateUser }) => {
|
||||
const { privateUser } = props
|
||||
const {privateUser} = props
|
||||
|
||||
const [prefs, setPrefs] =
|
||||
usePersistentInMemoryState<notification_preferences>(
|
||||
@@ -195,14 +193,14 @@ const LoadedNotificationSettings = (props: { privateUser: PrivateUser }) => {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="flex flex-col gap-8 p-4">
|
||||
{notificationTypes.map(({ type, question }) => (
|
||||
{notificationTypes.map(({type, question}) => (
|
||||
<NotificationOption
|
||||
key={type}
|
||||
type={type}
|
||||
question={question}
|
||||
selected={prefs[type]}
|
||||
onUpdate={(selected) => {
|
||||
setPrefs((prevPrefs) => ({ ...prevPrefs, [type]: selected }))
|
||||
setPrefs((prevPrefs) => ({...prevPrefs, [type]: selected}))
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -217,7 +215,7 @@ const NotificationOption = (props: {
|
||||
selected: notification_destination_types[]
|
||||
onUpdate: (selected: notification_destination_types[]) => void
|
||||
}) => {
|
||||
const { type, question, selected, onUpdate } = props
|
||||
const {type, question, selected, onUpdate} = props
|
||||
|
||||
const getSelectedValues = (destinations: string[]) => {
|
||||
const values: number[] = []
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import {LovePage} from 'web/components/love-page'
|
||||
import {GeneralButton} from "web/components/buttons/general-button";
|
||||
import clsx from "clsx";
|
||||
import {Col} from "web/components/layout/col";
|
||||
|
||||
|
||||
export default function Organization() {
|
||||
return (
|
||||
<LovePage trackPageView={'social'}>
|
||||
<div className="text-gray-600 dark:text-white min-h-screen p-6">
|
||||
<div className="w-full">
|
||||
<div className="relative py-8 mt-12 overflow-hidden">
|
||||
<div className="relative z-10 max-w-3xl mx-auto px-4">
|
||||
<h3 className="text-4xl font-bold text-center mt-8 mb-8">Organization</h3>
|
||||
<GeneralButton url={'/support'} content={'Support'}/>
|
||||
<GeneralButton url={'/constitution'} content={'Constitution'}/>
|
||||
<GeneralButton url={'/financials'} content={'Financials'}/>
|
||||
<GeneralButton url={'/terms'} content={'Terms and Conditions'}/>
|
||||
<GeneralButton url={'/privacy'} content={'Privacy Policy'}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-4xl font-bold text-center mt-8 mb-8">Organization</h3>
|
||||
<Col
|
||||
className={clsx(
|
||||
'pb-[58px] lg:pb-0', // bottom bar padding
|
||||
'text-ink-1000 mx-auto w-full grid grid-cols-1 gap-8 max-w-3xl sm:grid-cols-2 lg:min-h-0 lg:pt-4 mt-4',
|
||||
)}
|
||||
>
|
||||
<GeneralButton url={'/support'} content={'Support'}/>
|
||||
<GeneralButton url={'/constitution'} content={'Constitution'}/>
|
||||
<GeneralButton url={'/vote'} content={'Proposals'}/>
|
||||
<GeneralButton url={'/financials'} content={'Financials'}/>
|
||||
<GeneralButton url={'/stats'} content={'Growth & Stats'}/>
|
||||
<GeneralButton url={'/terms'} content={'Terms and Conditions'}/>
|
||||
<GeneralButton url={'/privacy'} content={'Privacy Policy'}/>
|
||||
<GeneralButton url={'/contact'} content={'Contact'}/>
|
||||
</Col>
|
||||
</LovePage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,10 @@ function RegisterComponent() {
|
||||
if (user) {
|
||||
const profile = await getProfileRow(user.id, db)
|
||||
if (profile) {
|
||||
console.log("Router.push('/')")
|
||||
await Router.push('/')
|
||||
} else {
|
||||
console.log("Router.push('/signup')")
|
||||
await Router.push('/signup')
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {initialRequiredState, RequiredLoveUserForm,} from 'web/components/required-profile-form'
|
||||
import {OptionalLoveUserForm} from 'web/components/optional-profile-form'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {CACHED_REFERRAL_USERNAME_KEY,} from 'web/lib/firebase/users'
|
||||
import {api} from 'web/lib/api'
|
||||
import Router, {useRouter} from 'next/router'
|
||||
import SiteLogo from 'web/components/site-logo'
|
||||
import {useRouter} from 'next/router'
|
||||
import {useTracking} from 'web/hooks/use-tracking'
|
||||
import {track} from 'web/lib/service/analytics'
|
||||
import {safeLocalStorage} from 'web/lib/util/local'
|
||||
@@ -15,7 +14,6 @@ import {removeNullOrUndefinedProps} from 'common/util/object'
|
||||
import {useProfileByUserId} from 'web/hooks/use-profile'
|
||||
import {ProfileRow} from 'common/love/profile'
|
||||
import {LovePage} from "web/components/love-page";
|
||||
import {Button} from "web/components/buttons/button";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [step, setStep] = useState(0)
|
||||
@@ -24,6 +22,36 @@ export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
useTracking('view love signup page')
|
||||
|
||||
// Hold loading indicator for 5s when user transitions from undefined -> null
|
||||
const prevUserRef = useRef<ReturnType<typeof useUser>>()
|
||||
const holdTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [holdLoading, setHoldLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevUserRef.current
|
||||
// Transition: undefined -> null
|
||||
if (prev === undefined && user === null) {
|
||||
setHoldLoading(true)
|
||||
if (holdTimeoutRef.current) clearTimeout(holdTimeoutRef.current)
|
||||
holdTimeoutRef.current = setTimeout(() => {
|
||||
setHoldLoading(false)
|
||||
holdTimeoutRef.current = null
|
||||
}, 10000)
|
||||
}
|
||||
// If user becomes defined, stop holding immediately
|
||||
if (user && holdLoading) {
|
||||
setHoldLoading(false)
|
||||
if (holdTimeoutRef.current) {
|
||||
clearTimeout(holdTimeoutRef.current)
|
||||
holdTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
prevUserRef.current = user
|
||||
return () => {
|
||||
// no-op
|
||||
}
|
||||
}, [user, holdLoading])
|
||||
|
||||
// Omit the id, created_time?
|
||||
const [profileForm, setProfileForm] = useState<ProfileRow>({
|
||||
...initialRequiredState,
|
||||
@@ -55,23 +83,15 @@ export default function SignupPage() {
|
||||
</LovePage>
|
||||
}
|
||||
|
||||
if (user === null && !holdLoading) {
|
||||
console.log('user === null && !holdLoading')
|
||||
return <CompassLoadingIndicator/>
|
||||
}
|
||||
|
||||
return (
|
||||
<Col className="items-center">
|
||||
{user === undefined ? (
|
||||
<div/>
|
||||
) : user === null ? (
|
||||
<Col className={'items-center justify-around gap-4 pt-[20vh]'}>
|
||||
<SiteLogo/>
|
||||
<Button
|
||||
color={'gray-outline'}
|
||||
size={'2xl'}
|
||||
className={''}
|
||||
onClick={() => {
|
||||
Router.push('register')
|
||||
}}>
|
||||
Sign up
|
||||
</Button>
|
||||
</Col>
|
||||
{!user ? (
|
||||
<CompassLoadingIndicator/>
|
||||
) : (
|
||||
<Col className={'w-full max-w-2xl px-6 py-4'}>
|
||||
{step === 0 ? (
|
||||
@@ -112,9 +132,9 @@ export default function SignupPage() {
|
||||
}}
|
||||
/>
|
||||
) : step === 1 ? (
|
||||
<></>
|
||||
<CompassLoadingIndicator/>
|
||||
) : (
|
||||
<LoadingIndicator/>
|
||||
<CompassLoadingIndicator/>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import {LovePage} from 'web/components/love-page'
|
||||
import {discordLink, githubRepo, redditLink, stoatLink, supportEmail, xLink} from "common/constants";
|
||||
import {GeneralButton} from "web/components/buttons/general-button";
|
||||
import clsx from "clsx";
|
||||
import {Col} from "web/components/layout/col";
|
||||
|
||||
|
||||
export default function Social() {
|
||||
return (
|
||||
<LovePage trackPageView={'social'}>
|
||||
<div className="text-gray-600 dark:text-white min-h-screen p-6">
|
||||
<div className="w-full">
|
||||
<div className="relative py-8 mt-12 overflow-hidden">
|
||||
<div className="relative z-10 max-w-3xl mx-auto px-4">
|
||||
<h3 className="text-4xl font-bold text-center mt-8 mb-8">Socials</h3>
|
||||
<GeneralButton url={discordLink} content={'Discord'}/>
|
||||
<GeneralButton url={stoatLink} content={'Revolt / Stoat'}/>
|
||||
<GeneralButton url={redditLink} content={'Reddit'}/>
|
||||
<GeneralButton url={githubRepo} content={'GitHub'}/>
|
||||
<GeneralButton url={xLink} content={'X'}/>
|
||||
<GeneralButton url={`mailto:${supportEmail}`} content={`${supportEmail}`}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-4xl font-bold text-center mt-8 mb-8">Socials</h3>
|
||||
<Col
|
||||
className={clsx(
|
||||
'pb-[58px] lg:pb-0', // bottom bar padding
|
||||
'text-ink-1000 mx-auto w-full grid grid-cols-1 gap-8 max-w-3xl sm:grid-cols-2 lg:min-h-0 lg:pt-4 mt-4',
|
||||
)}
|
||||
>
|
||||
<GeneralButton url={discordLink} content={'Discord'}/>
|
||||
<GeneralButton url={stoatLink} content={'Revolt / Stoat'}/>
|
||||
<GeneralButton url={redditLink} content={'Reddit'}/>
|
||||
<GeneralButton url={githubRepo} content={'GitHub'}/>
|
||||
<GeneralButton url={xLink} content={'X'}/>
|
||||
<GeneralButton url={`mailto:${supportEmail}`} content={`${supportEmail}`}/>
|
||||
</Col>
|
||||
</LovePage>
|
||||
)
|
||||
}
|
||||
|
||||
65
web/pages/stats.tsx
Normal file
65
web/pages/stats.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {LovePage} from "web/components/love-page";
|
||||
import ChartMembers from "web/components/widgets/charts";
|
||||
import {getCount} from "web/lib/supabase/users";
|
||||
import {useEffect, useState} from "react";
|
||||
import StatBox from "web/components/widgets/stat-box";
|
||||
import clsx from "clsx";
|
||||
import {Col} from "web/components/layout/col";
|
||||
|
||||
export default function Stats() {
|
||||
const [data, setData] = useState<Record<string, number | null>>({})
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const tables = [
|
||||
'profiles',
|
||||
'active_members',
|
||||
'bookmarked_searches',
|
||||
'private_user_message_channels',
|
||||
'private_user_messages',
|
||||
'profile_comments',
|
||||
'love_compatibility_answers',
|
||||
] as const
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
tables.map((t) => getCount(t))
|
||||
)
|
||||
|
||||
const result: Record<string, number | null> = {}
|
||||
settled.forEach((res, i) => {
|
||||
const key = tables[i]
|
||||
if (res.status === 'fulfilled') result[key] = res.value
|
||||
else result[key] = null
|
||||
})
|
||||
|
||||
setData(result)
|
||||
}
|
||||
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LovePage trackPageView={'charts'}>
|
||||
<h1 className="text-3xl font-semibold text-center mb-6">Growth & Stats</h1>
|
||||
<Col className={'sm:mx-4 mx-1 mb-8'}>
|
||||
<ChartMembers/>
|
||||
</Col>
|
||||
<Col className={'mx-4 mb-8'}>
|
||||
<Col
|
||||
className={clsx(
|
||||
'pb-[58px] lg:pb-0', // bottom bar padding
|
||||
'text-ink-1000 mx-auto w-full grid grid-cols-1 gap-8 max-w-3xl sm:grid-cols-2 lg:min-h-0 lg:pt-4 mt-4',
|
||||
)}
|
||||
>
|
||||
{!!data.profiles && <StatBox value={data.profiles} label={'Members'} />}
|
||||
{!!data.active_members && <StatBox value={data.active_members} label={'Active Members (last month)'} />}
|
||||
{!!data.private_user_message_channels && <StatBox value={data.private_user_message_channels} label={'Discussions'} />}
|
||||
{!!data.private_user_messages && <StatBox value={data.private_user_messages} label={'Messages'} />}
|
||||
{!!data.bookmarked_searches && <StatBox value={data.bookmarked_searches} label={'Searches Bookmarked'} />}
|
||||
{!!data.profile_comments && <StatBox value={data.profile_comments} label={'Endorsements'} />}
|
||||
{!!data.love_compatibility_answers && <StatBox value={data.love_compatibility_answers} label={'Prompts Answered'} />}
|
||||
</Col>
|
||||
</Col>
|
||||
</LovePage>
|
||||
);
|
||||
}
|
||||
33
web/pages/vote.tsx
Normal file
33
web/pages/vote.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {LovePage} from 'web/components/love-page'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {SEO} from 'web/components/SEO'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
|
||||
import {VoteComponent} from "web/components/votes/vote-info";
|
||||
|
||||
|
||||
export default function VotePage() {
|
||||
const user = useUser()
|
||||
|
||||
// console.log('user:', user)
|
||||
|
||||
return (
|
||||
<LovePage
|
||||
trackPageView={'vote page'}
|
||||
className={'relative p-2 sm:pt-0'}
|
||||
>
|
||||
<SEO
|
||||
title={`Votes`}
|
||||
description={'A place to vote on decisions'}
|
||||
url={`/vote`}
|
||||
/>
|
||||
{user === undefined ? (
|
||||
<CompassLoadingIndicator/>
|
||||
) : (
|
||||
<Col className={'gap-4'}>
|
||||
<VoteComponent/>
|
||||
</Col>
|
||||
)}
|
||||
</LovePage>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ Martin continues to serve as an initiator and steward of Compass, but its direct
|
||||
|
||||
Compass is run democratically under a [constitution](/constitution) that prevents central control and ensures long-term alignment with its mission.
|
||||
|
||||
* Major decisions (scope, funding, rules) are voted on by **active contributors**.
|
||||
* Major decisions (scope, funding, rules) are [voted](/vote) on by **active contributors**.
|
||||
* The full constitution is **public and transparent**.
|
||||
* No corporate capture — Compass will always remain a community-owned project.
|
||||
|
||||
@@ -103,16 +103,38 @@ We chose the name Compass because our goal is to help people orient themselves t
|
||||
|
||||
* **Give Feedback**: [Fill out the suggestion form](https://forms.gle/tKnXUMAbEreMK6FC6)
|
||||
* **Join the Discussion**: [Discord Community](https://discord.gg/8Vd7jzqjun)
|
||||
* **Vote on proposals**: [vote here](/vote)
|
||||
* **Contribute to Development**: [View the code on GitHub](https://github.com/CompassConnections/Compass)
|
||||
* **Donate**: [Support the infrastructure](/support)
|
||||
* **Spread the Word**: Tell friends and family who value depth and real connection.
|
||||
|
||||
### How can I contact the community?
|
||||
|
||||
You can reach us through the [contact form](/contact), the [feedback form](https://forms.gle/tKnXUMAbEreMK6FC6), or any of our [socials](/social).
|
||||
|
||||
### How fast is Compass growing?
|
||||
|
||||
Compass has officially **launched** in October 2025 and is growing fast. You can explore real-time stats and transparent community data on our [**Growth & Stats page**](/stats). It includes information such as:
|
||||
|
||||
* Community growth over time
|
||||
* Messages sent
|
||||
* Discussions started
|
||||
* Search Bookmarks created
|
||||
* Endorsements given
|
||||
* Prompts answered
|
||||
|
||||
[//]: # (* Feature votes and participation rates)
|
||||
[//]: # (* Contributions and donations)
|
||||
[//]: # (* Number of active users)
|
||||
|
||||
Because Compass is fully transparent and community-owned, you can see how the ecosystem evolves — not just in numbers, but in how people connect, collaborate, and help shape the platform together.
|
||||
|
||||
### What’s the long-term vision?
|
||||
|
||||
Our goal is for Compass to become what Linux is for software, Wikipedia is for knowledge, or Firefox is for browsing — a public, open-source infrastructure that anyone can use, contribute to, and trust. We believe meaningful human connection deserves the same treatment: free, transparent, community-owned, and protected from corporate capture.
|
||||
|
||||
### What’s next?
|
||||
|
||||
Compass has officially **launched** in October 2025 and is growing fast. Our focus is now toward **gathering feedback**, **growing the community** and **securing donations** to sustain and expand the platform.
|
||||
Our focus is now toward **gathering feedback**, **growing the community** and **securing donations** to sustain and expand the platform.
|
||||
|
||||
Every action, whether sharing, donating, or contributing, directly helps Compass remain **ad-free, subscription-free, and community-owned**.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user