mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-05-18 22:02:07 -04:00
Store option IDs instead of EN labels in profiles and make keyword search match selected options
This commit is contained in:
@@ -8,8 +8,8 @@ android {
|
||||
applicationId "com.compassconnections.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 24
|
||||
versionName "1.2.0"
|
||||
versionCode 25
|
||||
versionName "1.3.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@compass/api",
|
||||
"description": "Backend API endpoints",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"watch:serve": "tsx watch src/serve.ts",
|
||||
|
||||
@@ -38,6 +38,7 @@ export type profileQueryType = {
|
||||
skipId?: string | undefined,
|
||||
orderBy?: string | undefined,
|
||||
lastModificationWithin?: string | undefined,
|
||||
locale?: string | undefined,
|
||||
} & {
|
||||
[K in OptionTableKey]?: string[] | undefined
|
||||
}
|
||||
@@ -82,6 +83,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
orderBy: orderByParam = 'created_time',
|
||||
lastModificationWithin,
|
||||
skipId,
|
||||
locale = 'en',
|
||||
} = props
|
||||
|
||||
const filterLocation = lat && lon && radius
|
||||
@@ -102,6 +104,10 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
|
||||
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||
|
||||
const joinInterests = !!interests?.length
|
||||
const joinCauses = !!causes?.length
|
||||
const joinWork = !!work?.length
|
||||
|
||||
// Pre-aggregated interests per profile
|
||||
function getManyToManyJoin(label: OptionTableKey) {
|
||||
return `(
|
||||
@@ -122,9 +128,9 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
const joins = [
|
||||
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
|
||||
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
|
||||
interests && leftJoin(interestsJoin),
|
||||
causes && leftJoin(causesJoin),
|
||||
work && leftJoin(workJoin),
|
||||
joinInterests && leftJoin(interestsJoin),
|
||||
joinCauses && leftJoin(causesJoin),
|
||||
joinWork && leftJoin(workJoin),
|
||||
]
|
||||
|
||||
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
|
||||
@@ -146,10 +152,23 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
SELECT 1 FROM profile_${label}
|
||||
JOIN ${label} ON ${label}.id = profile_${label}.option_id
|
||||
WHERE profile_${label}.profile_id = profiles.id
|
||||
AND ${label}.name = ANY (ARRAY[$(values)])
|
||||
AND ${label}.id = ANY (ARRAY[$(values)])
|
||||
)`
|
||||
}
|
||||
|
||||
function getOptionClauseKeyword(label: OptionTableKey) {
|
||||
return `EXISTS (
|
||||
SELECT 1 FROM profile_${label}
|
||||
JOIN ${label} ON ${label}.id = profile_${label}.option_id
|
||||
LEFT JOIN ${label}_translations
|
||||
ON ${label}_translations.option_id = profile_${label}.option_id
|
||||
AND ${label}_translations.locale = $(locale)
|
||||
WHERE profile_${label}.profile_id = profiles.id
|
||||
AND lower(COALESCE(${label}_translations.name, ${label}.name)) ILIKE '%' || lower($(word)) || '%'
|
||||
)`
|
||||
}
|
||||
|
||||
|
||||
const filters = [
|
||||
where('looking_for_matches = true'),
|
||||
where(`profiles.disabled != true`),
|
||||
@@ -160,8 +179,14 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
||||
|
||||
...keywords.map(word => where(
|
||||
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
|
||||
{word}
|
||||
`lower(users.name) ilike '%' || lower($(word)) || '%'
|
||||
or lower(search_text) ilike '%' || lower($(word)) || '%'
|
||||
or search_tsv @@ phraseto_tsquery('english', $(word))
|
||||
OR ${getOptionClauseKeyword('interests')}
|
||||
OR ${getOptionClauseKeyword('causes')}
|
||||
OR ${getOptionClauseKeyword('work')}
|
||||
`,
|
||||
{word, locale}
|
||||
)),
|
||||
|
||||
genders?.length && where(`gender = ANY($(genders))`, {genders}),
|
||||
@@ -227,7 +252,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
{religion}
|
||||
),
|
||||
|
||||
interests?.length && where(getManyToManyClause('interests'), {values: interests}),
|
||||
interests?.length && where(getManyToManyClause('interests'), {values: interests.map(Number)}),
|
||||
|
||||
causes?.length && where(getManyToManyClause('causes'), {values: causes}),
|
||||
|
||||
@@ -278,9 +303,9 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
} else if (orderByParam === 'last_online_time') {
|
||||
selectCols += ', user_activity.last_online_time'
|
||||
}
|
||||
if (interests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
|
||||
if (causes) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
|
||||
if (work) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
|
||||
if (joinInterests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
|
||||
if (joinCauses) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
|
||||
if (joinWork) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
|
||||
|
||||
const query = renderSql(
|
||||
select(selectCols),
|
||||
|
||||
@@ -5,15 +5,22 @@ import {tryCatch} from 'common/util/try-catch'
|
||||
import {OPTION_TABLES} from "common/profiles/constants";
|
||||
|
||||
export const updateOptions: APIHandler<'update-options'> = async (
|
||||
{table, names},
|
||||
{table, values},
|
||||
auth
|
||||
) => {
|
||||
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
|
||||
if (!names || !Array.isArray(names) || names.length === 0) {
|
||||
throw new APIError(400, 'No names provided')
|
||||
if (!values || !Array.isArray(values)) {
|
||||
throw new APIError(400, 'No ids provided')
|
||||
}
|
||||
|
||||
log('Updating profile options', {table, names})
|
||||
const idsWithNumbers = values.map(id => {
|
||||
const numberId = Number(id)
|
||||
return isNaN(numberId) ? {isNumber: false, v: id} : {isNumber: true, v: numberId}
|
||||
})
|
||||
const names: string[] = idsWithNumbers.filter(item => !item.isNumber).map(item => item.v) as string[]
|
||||
const ids: number[] = idsWithNumbers.filter(item => item.isNumber).map(item => item.v) as number[]
|
||||
|
||||
log('Updating profile options', {table, ids, names})
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
@@ -25,8 +32,20 @@ export const updateOptions: APIHandler<'update-options'> = async (
|
||||
const profileId = profileIdResult.id
|
||||
|
||||
const result = await tryCatch(pg.tx(async (t) => {
|
||||
const ids: number[] = []
|
||||
for (const name of names) {
|
||||
const currentOptionsResult = await t.manyOrNone<{ id: string }>(
|
||||
`SELECT option_id as id
|
||||
FROM profile_${table}
|
||||
WHERE profile_id = $1`,
|
||||
[profileId]
|
||||
)
|
||||
const currentOptions = currentOptionsResult.map(row => row.id)
|
||||
if (currentOptions.sort().join(',') === ids.sort().join(',') && !names?.length) {
|
||||
log(`Skipping /update-${table} because they are already the same`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Add new options
|
||||
for (const name of (names || [])) {
|
||||
const row = await t.one<{ id: number }>(
|
||||
`INSERT INTO ${table} (name, creator_id)
|
||||
VALUES ($1, $2)
|
||||
|
||||
@@ -110,6 +110,8 @@ export const sinclairProfile: ProfileRow = {
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
search_text: 'the futa in futarchy',
|
||||
search_tsv: 'the futa in futarchy',
|
||||
age: 25,
|
||||
}
|
||||
|
||||
@@ -221,5 +223,7 @@ export const jamesProfile: ProfileRow = {
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
search_text: 'the futa in futarchy',
|
||||
search_tsv: 'the futa in futarchy',
|
||||
age: 32,
|
||||
}
|
||||
|
||||
25
backend/supabase/causes_translations.sql
Normal file
25
backend/supabase/causes_translations.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS causes_translations
|
||||
(
|
||||
option_id BIGINT NOT NULL REFERENCES causes (id) ON DELETE CASCADE,
|
||||
locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc.
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (option_id, locale)
|
||||
);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE causes_translations
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "public read" ON causes_translations;
|
||||
CREATE POLICY "public read" ON causes_translations
|
||||
FOR SELECT USING (true);
|
||||
|
||||
CREATE INDEX idx_causes_translations_option_locale
|
||||
ON causes_translations (option_id, locale);
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX idx_causes_translations_name_trgm
|
||||
ON causes_translations
|
||||
USING GIN (name gin_trgm_ops);
|
||||
|
||||
27
backend/supabase/interests_translations.sql
Normal file
27
backend/supabase/interests_translations.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS interests_translations
|
||||
(
|
||||
option_id BIGINT NOT NULL REFERENCES interests (id) ON DELETE CASCADE,
|
||||
locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc.
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (option_id, locale)
|
||||
);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE interests_translations
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "public read" ON interests_translations;
|
||||
CREATE POLICY "public read" ON interests_translations
|
||||
FOR SELECT USING (true);
|
||||
|
||||
DROP INDEX IF EXISTS idx_interests_translations_option_locale;
|
||||
CREATE INDEX idx_interests_translations_option_locale
|
||||
ON interests_translations (option_id, locale);
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
DROP INDEX IF EXISTS idx_interests_translations_name_trgm;
|
||||
CREATE INDEX idx_interests_translations_name_trgm
|
||||
ON interests_translations
|
||||
USING GIN (name gin_trgm_ops);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
BEGIN;
|
||||
\i backend/supabase/rebuild_profile_search.sql
|
||||
\i backend/supabase/functions.sql
|
||||
\i backend/supabase/firebase.sql
|
||||
\i backend/supabase/profiles.sql
|
||||
|
||||
@@ -21,3 +21,27 @@ CREATE INDEX idx_profile_causes_profile
|
||||
|
||||
CREATE INDEX idx_profile_causes_interest
|
||||
ON profile_causes (option_id);
|
||||
|
||||
-- Trigger to update /get-profiles search
|
||||
CREATE OR REPLACE FUNCTION trg_profile_causes_rebuild_search()
|
||||
RETURNS trigger AS
|
||||
$$
|
||||
BEGIN
|
||||
PERFORM rebuild_profile_search(
|
||||
COALESCE(NEW.profile_id, OLD.profile_id)
|
||||
);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_profile_causes_search_ins
|
||||
AFTER INSERT
|
||||
ON profile_causes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trg_profile_causes_rebuild_search();
|
||||
|
||||
CREATE TRIGGER trg_profile_causes_search_del
|
||||
AFTER DELETE
|
||||
ON profile_causes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trg_profile_causes_rebuild_search();
|
||||
|
||||
@@ -21,3 +21,28 @@ CREATE INDEX idx_profile_interests_profile
|
||||
|
||||
CREATE INDEX idx_profile_interests_interest
|
||||
ON profile_interests (option_id);
|
||||
|
||||
-- Trigger to update /get-profiles search
|
||||
CREATE OR REPLACE FUNCTION trg_profile_interests_rebuild_search()
|
||||
RETURNS trigger AS
|
||||
$$
|
||||
BEGIN
|
||||
PERFORM rebuild_profile_search(
|
||||
COALESCE(NEW.profile_id, OLD.profile_id)
|
||||
);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_profile_interests_search_ins
|
||||
AFTER INSERT
|
||||
ON profile_interests
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trg_profile_interests_rebuild_search();
|
||||
|
||||
CREATE TRIGGER trg_profile_interests_search_del
|
||||
AFTER DELETE
|
||||
ON profile_interests
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trg_profile_interests_rebuild_search();
|
||||
|
||||
|
||||
@@ -21,3 +21,27 @@ CREATE INDEX idx_profile_work_profile
|
||||
|
||||
CREATE INDEX idx_profile_work_interest
|
||||
ON profile_work (option_id);
|
||||
|
||||
-- Trigger to update /get-profiles search
|
||||
CREATE OR REPLACE FUNCTION trg_profile_work_rebuild_search()
|
||||
RETURNS trigger AS
|
||||
$$
|
||||
BEGIN
|
||||
PERFORM rebuild_profile_search(
|
||||
COALESCE(NEW.profile_id, OLD.profile_id)
|
||||
);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_profile_work_search_ins
|
||||
AFTER INSERT
|
||||
ON profile_work
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trg_profile_work_rebuild_search();
|
||||
|
||||
CREATE TRIGGER trg_profile_work_search_del
|
||||
AFTER DELETE
|
||||
ON profile_work
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trg_profile_work_rebuild_search();
|
||||
|
||||
@@ -123,28 +123,35 @@ CREATE INDEX profiles_bio_trgm_idx
|
||||
|
||||
--- bio_text
|
||||
ALTER TABLE profiles ADD COLUMN bio_text TEXT;
|
||||
UPDATE profiles
|
||||
SET bio_text = (
|
||||
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
|
||||
FROM jsonb_path_query(bio, '$.**.text') AS t(value)
|
||||
);
|
||||
|
||||
ALTER TABLE profiles ADD COLUMN bio_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED;
|
||||
|
||||
CREATE INDEX profiles_bio_tsv_idx ON profiles USING GIN (bio_tsv);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_bio_text()
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN search_text TEXT,
|
||||
ADD COLUMN search_tsv tsvector;
|
||||
|
||||
-- Rebuild search (search_txt and search_tsv)
|
||||
CREATE OR REPLACE FUNCTION trg_profiles_rebuild_search()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.bio_text := (
|
||||
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
|
||||
FROM jsonb_path_query(NEW.bio, '$.**.text') AS t(value)
|
||||
);
|
||||
PERFORM rebuild_profile_search(NEW.id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_update_bio_text
|
||||
BEFORE INSERT OR UPDATE OF bio ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_bio_text();
|
||||
DROP FUNCTION IF EXISTS update_bio_text;
|
||||
DROP TRIGGER IF EXISTS trg_update_bio_text ON profiles;
|
||||
|
||||
CREATE TRIGGER trg_profiles_rebuild_search
|
||||
AFTER INSERT OR UPDATE OF bio
|
||||
ON profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trg_profiles_rebuild_search();
|
||||
|
||||
CREATE INDEX profiles_search_tsv_idx
|
||||
ON profiles USING GIN (search_tsv);
|
||||
|
||||
|
||||
|
||||
44
backend/supabase/rebuild_profile_search.sql
Normal file
44
backend/supabase/rebuild_profile_search.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
CREATE OR REPLACE FUNCTION rebuild_profile_search(profile_id_param BIGINT)
|
||||
RETURNS void AS
|
||||
$$
|
||||
DECLARE
|
||||
bio_part TEXT;
|
||||
interests_part TEXT;
|
||||
causes_part TEXT;
|
||||
work_part TEXT;
|
||||
BEGIN
|
||||
-- Bio text
|
||||
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
|
||||
INTO bio_part
|
||||
FROM profiles p,
|
||||
jsonb_path_query(p.bio, '$.**.text') AS t(value)
|
||||
WHERE p.id = profile_id_param;
|
||||
|
||||
-- Interests text
|
||||
SELECT string_agg(i.name, ' ')
|
||||
INTO interests_part
|
||||
FROM profile_interests pi
|
||||
JOIN interests i ON i.id = pi.option_id
|
||||
WHERE pi.profile_id = profile_id_param;
|
||||
|
||||
-- Causes text
|
||||
SELECT string_agg(i.name, ' ')
|
||||
INTO causes_part
|
||||
FROM profile_causes pc
|
||||
JOIN causes i ON i.id = pc.option_id
|
||||
WHERE pc.profile_id = profile_id_param;
|
||||
|
||||
-- Work text
|
||||
SELECT string_agg(i.name, ' ')
|
||||
INTO work_part
|
||||
FROM profile_work pw
|
||||
JOIN work i ON i.id = pw.option_id
|
||||
WHERE pw.profile_id = profile_id_param;
|
||||
|
||||
UPDATE profiles
|
||||
SET bio_text = bio_part,
|
||||
search_text = concat_ws(' ', bio_part, interests_part, causes_part, work_part),
|
||||
search_tsv = to_tsvector('english', concat_ws(' ', bio_part, interests_part, causes_part, work_part))
|
||||
WHERE id = profile_id_param;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
25
backend/supabase/work_translations.sql
Normal file
25
backend/supabase/work_translations.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS work_translations
|
||||
(
|
||||
option_id BIGINT NOT NULL REFERENCES work (id) ON DELETE CASCADE,
|
||||
locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc.
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (option_id, locale)
|
||||
);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE work_translations
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "public read" ON work_translations;
|
||||
CREATE POLICY "public read" ON work_translations
|
||||
FOR SELECT USING (true);
|
||||
|
||||
CREATE INDEX idx_work_translations_option_locale
|
||||
ON work_translations (option_id, locale);
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX idx_work_translations_name_trgm
|
||||
ON work_translations
|
||||
USING GIN (name gin_trgm_ops);
|
||||
|
||||
@@ -482,6 +482,7 @@ export const API = (_apiTypeCheck = {
|
||||
radius: z.coerce.number().optional(),
|
||||
compatibleWithUserId: z.string().optional(),
|
||||
skipId: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
orderBy: z
|
||||
.enum(['last_online_time', 'created_time', 'compatibility_score'])
|
||||
.optional()
|
||||
@@ -504,6 +505,7 @@ export const API = (_apiTypeCheck = {
|
||||
props: z
|
||||
.object({
|
||||
table: z.enum(OPTION_TABLES),
|
||||
locale: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
summary: 'Get profile options like interests',
|
||||
@@ -517,7 +519,7 @@ export const API = (_apiTypeCheck = {
|
||||
props: z
|
||||
.object({
|
||||
table: z.enum(OPTION_TABLES),
|
||||
names: arraybeSchema.optional(),
|
||||
values: arraybeSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
summary: 'Update profile options like interests',
|
||||
|
||||
@@ -21,30 +21,30 @@ export const getProfileRow = async (userId: string, db: SupabaseClient): Promise
|
||||
const interestsRes = await run(
|
||||
db
|
||||
.from('profile_interests')
|
||||
.select('interests(name)')
|
||||
.select('interests(name, id)')
|
||||
.eq('profile_id', profile.id)
|
||||
)
|
||||
const interests = interestsRes.data?.map((row: any) => row.interests.name) || []
|
||||
const interests = interestsRes.data?.map((row: any) => String(row.interests.id)) || []
|
||||
|
||||
// Fetch causes
|
||||
const causesRes = await run(
|
||||
db
|
||||
.from('profile_causes')
|
||||
.select('causes(name)')
|
||||
.select('causes(name, id)')
|
||||
.eq('profile_id', profile.id)
|
||||
)
|
||||
const causes = causesRes.data?.map((row: any) => row.causes.name) || []
|
||||
const causes = causesRes.data?.map((row: any) => String(row.causes.id)) || []
|
||||
|
||||
// Fetch causes
|
||||
const workRes = await run(
|
||||
db
|
||||
.from('profile_work')
|
||||
.select('work(name)')
|
||||
.select('work(name, id)')
|
||||
.eq('profile_id', profile.id)
|
||||
)
|
||||
const work = workRes.data?.map((row: any) => row.work.name) || []
|
||||
const work = workRes.data?.map((row: any) => String(row.work.id)) || []
|
||||
|
||||
// console.debug('work', work)
|
||||
// console.debug({work, interests, causes})
|
||||
|
||||
return {
|
||||
...profile,
|
||||
|
||||
@@ -78,6 +78,32 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
causes_translations: {
|
||||
Row: {
|
||||
locale: string
|
||||
name: string
|
||||
option_id: number
|
||||
}
|
||||
Insert: {
|
||||
locale: string
|
||||
name: string
|
||||
option_id: number
|
||||
}
|
||||
Update: {
|
||||
locale?: string
|
||||
name?: string
|
||||
option_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'causes_translations_option_id_fkey'
|
||||
columns: ['option_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'causes'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
compatibility_answers: {
|
||||
Row: {
|
||||
created_time: string
|
||||
@@ -327,6 +353,32 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
interests_translations: {
|
||||
Row: {
|
||||
locale: string
|
||||
name: string
|
||||
option_id: number
|
||||
}
|
||||
Insert: {
|
||||
locale: string
|
||||
name: string
|
||||
option_id: number
|
||||
}
|
||||
Update: {
|
||||
locale?: string
|
||||
name?: string
|
||||
option_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'interests_translations_option_id_fkey'
|
||||
columns: ['option_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'interests'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
private_user_message_channel_members: {
|
||||
Row: {
|
||||
channel_id: number
|
||||
@@ -814,6 +866,8 @@ export type Database = {
|
||||
religion: string[] | null
|
||||
religious_belief_strength: number | null
|
||||
religious_beliefs: string | null
|
||||
search_text: string | null
|
||||
search_tsv: unknown
|
||||
twitter: string | null
|
||||
university: string | null
|
||||
user_id: string
|
||||
@@ -869,6 +923,8 @@ export type Database = {
|
||||
religion?: string[] | null
|
||||
religious_belief_strength?: number | null
|
||||
religious_beliefs?: string | null
|
||||
search_text?: string | null
|
||||
search_tsv?: unknown
|
||||
twitter?: string | null
|
||||
university?: string | null
|
||||
user_id: string
|
||||
@@ -924,6 +980,8 @@ export type Database = {
|
||||
religion?: string[] | null
|
||||
religious_belief_strength?: number | null
|
||||
religious_beliefs?: string | null
|
||||
search_text?: string | null
|
||||
search_tsv?: unknown
|
||||
twitter?: string | null
|
||||
university?: string | null
|
||||
user_id?: string
|
||||
@@ -1289,6 +1347,32 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
work_translations: {
|
||||
Row: {
|
||||
locale: string
|
||||
name: string
|
||||
option_id: number
|
||||
}
|
||||
Insert: {
|
||||
locale: string
|
||||
name: string
|
||||
option_id: number
|
||||
}
|
||||
Update: {
|
||||
locale?: string
|
||||
name?: string
|
||||
option_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'work_translations_option_id_fkey'
|
||||
columns: ['option_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'work'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
@@ -1341,6 +1425,10 @@ export type Database = {
|
||||
}
|
||||
millis_to_ts: { Args: { millis: number }; Returns: string }
|
||||
random_alphanumeric: { Args: { length: number }; Returns: string }
|
||||
rebuild_profile_search: {
|
||||
Args: { profile_id_param: number }
|
||||
Returns: undefined
|
||||
}
|
||||
show_limit: { Args: never; Returns: number }
|
||||
show_trgm: { Args: { '': string }; Returns: string[] }
|
||||
to_jsonb: { Args: { '': Json }; Returns: Json }
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Col} from "web/components/layout/col";
|
||||
import clsx from "clsx";
|
||||
import {colClassName, labelClassName} from "web/pages/signup";
|
||||
import {MultiCheckbox} from "web/components/multi-checkbox";
|
||||
import {invert} from "lodash";
|
||||
import {useLocale} from "web/lib/locale";
|
||||
|
||||
export function AddOptionEntry(props: {
|
||||
title?: string
|
||||
@@ -14,12 +16,17 @@ export function AddOptionEntry(props: {
|
||||
label: OptionTableKey,
|
||||
}) {
|
||||
const {profile, setProfile, label, choices, setChoices, title} = props
|
||||
const {locale} = useLocale()
|
||||
const sortedChoices = Object.fromEntries(
|
||||
Object.entries(invert(choices)).sort((a, b) =>
|
||||
a[0].localeCompare(b[0], locale)
|
||||
)
|
||||
)
|
||||
return <Col className={clsx(colClassName)}>
|
||||
{title && <label className={clsx(labelClassName)}>{title}</label>}
|
||||
<MultiCheckbox
|
||||
choices={choices}
|
||||
selected={profile[label] ?? []}
|
||||
translationPrefix={`profile.${label}`}
|
||||
choices={sortedChoices}
|
||||
selected={(profile[label] ?? []).map((s) => String(s))}
|
||||
onChange={(selected) => setProfile(label, selected)}
|
||||
addOption={(v: string) => {
|
||||
console.log(`Adding ${label}:`, v)
|
||||
|
||||
@@ -52,7 +52,7 @@ export function DesktopFilters(props: {
|
||||
isYourFilters: boolean
|
||||
locationFilterProps: LocationFilterProps
|
||||
includeRelationshipFilters: boolean | undefined
|
||||
choices: Record<OptionTableKey, Record<string, string[]>>
|
||||
choices: Record<OptionTableKey, Record<string, string>>
|
||||
}) {
|
||||
const {
|
||||
filters,
|
||||
|
||||
@@ -3,8 +3,9 @@ import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-te
|
||||
import {MultiCheckbox} from 'web/components/multi-checkbox'
|
||||
import {FilterFields} from "common/filters";
|
||||
import {OptionTableKey} from 'common/profiles/constants'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {toKey} from "common/parsing";
|
||||
import {useLocale, useT} from 'web/lib/locale'
|
||||
import {useChoices} from "web/hooks/use-choices";
|
||||
import {invert} from "lodash";
|
||||
|
||||
export function InterestFilterText(props: {
|
||||
options: string[] | undefined
|
||||
@@ -14,6 +15,7 @@ export function InterestFilterText(props: {
|
||||
const {options, highlightedClass, label} = props
|
||||
const t = useT()
|
||||
const length = (options ?? []).length
|
||||
const {choices} = useChoices('interests')
|
||||
|
||||
if (!options || length < 1) {
|
||||
return (
|
||||
@@ -37,7 +39,7 @@ export function InterestFilterText(props: {
|
||||
<div>
|
||||
<span className={clsx('font-semibold', highlightedClass)}>
|
||||
{stringOrStringArrayToText({
|
||||
text: options.map((o) => t(`profile.${label}.${toKey(o)}`, o)),
|
||||
text: options.map(id => choices[id]),
|
||||
capitalizeFirstLetterOption: true,
|
||||
t: t,
|
||||
})}{' '}
|
||||
@@ -49,18 +51,21 @@ export function InterestFilterText(props: {
|
||||
export function InterestFilter(props: {
|
||||
filters: Partial<FilterFields>
|
||||
updateFilter: (newState: Partial<FilterFields>) => void
|
||||
choices: Record<string, string[]>
|
||||
choices: Record<string, string>
|
||||
label: OptionTableKey
|
||||
}) {
|
||||
const {filters, updateFilter, choices, label} = props
|
||||
const {locale} = useLocale()
|
||||
const sortedChoices = Object.fromEntries(
|
||||
Object.entries(invert(choices)).sort((a, b) =>
|
||||
a[0].localeCompare(b[0], locale)
|
||||
)
|
||||
)
|
||||
return (
|
||||
<MultiCheckbox
|
||||
selected={filters[label] ?? []}
|
||||
choices={choices as any}
|
||||
translationPrefix={`profile.${label}`}
|
||||
onChange={(c) => {
|
||||
updateFilter({[label]: c})
|
||||
}}
|
||||
choices={sortedChoices as any}
|
||||
onChange={(c) => updateFilter({[label]: c})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function MobileFilters(props: {
|
||||
isYourFilters: boolean
|
||||
locationFilterProps: LocationFilterProps
|
||||
includeRelationshipFilters: boolean | undefined
|
||||
choices: Record<OptionTableKey, Record<string, string[]>>
|
||||
choices: Record<OptionTableKey, Record<string, string>>
|
||||
}) {
|
||||
const t = useT()
|
||||
const {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Title} from 'web/components/widgets/title'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import clsx from 'clsx'
|
||||
import {MultiCheckbox} from 'web/components/multi-checkbox'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {useLocale, useT} from 'web/lib/locale'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {Input} from 'web/components/widgets/input'
|
||||
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
|
||||
@@ -81,11 +81,12 @@ export const OptionalProfileUserForm = (props: {
|
||||
const [interestChoices, setInterestChoices] = useState({})
|
||||
const [causeChoices, setCauseChoices] = useState({})
|
||||
const [workChoices, setWorkChoices] = useState({})
|
||||
const {locale} = useLocale()
|
||||
|
||||
useEffect(() => {
|
||||
fetchChoices('interests').then(setInterestChoices)
|
||||
fetchChoices('causes').then(setCauseChoices)
|
||||
fetchChoices('work').then(setWorkChoices)
|
||||
fetchChoices('interests', locale).then(setInterestChoices)
|
||||
fetchChoices('causes', locale).then(setCauseChoices)
|
||||
fetchChoices('work', locale).then(setWorkChoices)
|
||||
}, [db])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -104,14 +105,14 @@ export const OptionalProfileUserForm = (props: {
|
||||
const promises: Promise<any>[] = [
|
||||
tryCatch(updateProfile(removeUndefinedProps(otherProfileProps) as any))
|
||||
]
|
||||
if (interests?.length) {
|
||||
promises.push(api('update-options', {table: 'interests', names: interests}))
|
||||
if (interests) {
|
||||
promises.push(api('update-options', {table: 'interests', values: interests}))
|
||||
}
|
||||
if (causes?.length) {
|
||||
promises.push(api('update-options', {table: 'causes', names: causes}))
|
||||
if (causes) {
|
||||
promises.push(api('update-options', {table: 'causes', values: causes}))
|
||||
}
|
||||
if (work?.length) {
|
||||
promises.push(api('update-options', {table: 'work', names: work}))
|
||||
if (work) {
|
||||
promises.push(api('update-options', {table: 'work', values: work}))
|
||||
}
|
||||
try {
|
||||
await Promise.all(promises)
|
||||
|
||||
@@ -33,7 +33,7 @@ import {MAX_INT, MIN_INT} from "common/constants";
|
||||
import {GiFruitBowl} from "react-icons/gi";
|
||||
import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa";
|
||||
import {useLocale, useT} from "web/lib/locale";
|
||||
import {toKey} from "common/parsing";
|
||||
import {useChoices} from "web/hooks/use-choices";
|
||||
|
||||
export function AboutRow(props: {
|
||||
icon: ReactNode
|
||||
@@ -79,6 +79,11 @@ export default function ProfileAbout(props: {
|
||||
}) {
|
||||
const {profile, userActivity, isCurrentUser} = props
|
||||
const t = useT()
|
||||
const {choices: interestsById} = useChoices('interests')
|
||||
const {choices: causesById} = useChoices('causes')
|
||||
const {choices: workById} = useChoices('work')
|
||||
const {locale} = useLocale()
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx('bg-canvas-0 relative gap-3 overflow-hidden rounded p-4')}
|
||||
@@ -90,7 +95,7 @@ export default function ProfileAbout(props: {
|
||||
<Occupation profile={profile}/>
|
||||
<AboutRow
|
||||
icon={<FaBriefcase className="h-5 w-5"/>}
|
||||
text={profile.work?.map(work => t(`profile.work.${toKey(work)}`, work))}
|
||||
text={profile.work?.map(id => workById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]}
|
||||
/>
|
||||
<AboutRow
|
||||
icon={<RiScales3Line className="h-5 w-5"/>}
|
||||
@@ -104,11 +109,11 @@ export default function ProfileAbout(props: {
|
||||
/>
|
||||
<AboutRow
|
||||
icon={<FaStar className="h-5 w-5"/>}
|
||||
text={profile.interests?.map(interest => t(`profile.interests.${toKey(interest)}`, interest))}
|
||||
text={profile.interests?.map(id => interestsById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]}
|
||||
/>
|
||||
<AboutRow
|
||||
icon={<FaHandsHelping className="h-5 w-5"/>}
|
||||
text={profile.causes?.map(cause => t(`profile.causes.${toKey(cause)}`, cause))}
|
||||
text={profile.causes?.map(id => causesById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]}
|
||||
/>
|
||||
<AboutRow
|
||||
icon={<BsPersonVcard className="h-5 w-5"/>}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {useUser} from 'web/hooks/use-user'
|
||||
import {api} from 'web/lib/api'
|
||||
import {useBookmarkedSearches} from "web/hooks/use-bookmarked-searches"
|
||||
import {useFilters} from "web/components/filters/use-filters"
|
||||
import {useT} from "web/lib/locale";
|
||||
import {useLocale, useT} from "web/lib/locale";
|
||||
|
||||
export function ProfilesHome() {
|
||||
const user = useUser()
|
||||
@@ -35,6 +35,7 @@ export function ProfilesHome() {
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [isReloading, setIsReloading] = useState(false)
|
||||
const t = useT()
|
||||
const {locale} = useLocale()
|
||||
|
||||
// const [debouncedAgeRange, setRawAgeRange] = useState({
|
||||
// min: filters.pref_age_min ?? PREF_AGE_MIN,
|
||||
@@ -56,6 +57,7 @@ export function ProfilesHome() {
|
||||
const args = removeNullOrUndefinedProps({
|
||||
limit: 20,
|
||||
compatibleWithUserId: user?.id,
|
||||
locale,
|
||||
...filters
|
||||
})
|
||||
console.debug('Refreshing profiles, filters:', args)
|
||||
@@ -87,6 +89,7 @@ export function ProfilesHome() {
|
||||
limit: 20,
|
||||
compatibleWithUserId: user?.id,
|
||||
after: lastProfile?.id.toString(),
|
||||
locale,
|
||||
...filters
|
||||
}) as any)
|
||||
if (result.profiles.length === 0) return false
|
||||
|
||||
@@ -2,20 +2,40 @@ import {useEffect} from 'react'
|
||||
import {run} from 'common/supabase/utils'
|
||||
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
import {useLocale} from "web/lib/locale";
|
||||
|
||||
export async function fetchChoices(label: string) {
|
||||
const {data} = await run(db.from(label).select('name').order('name'))
|
||||
console.log('Fetched choices:', data)
|
||||
const results = Object.fromEntries(data.map((row: { name: string }) => [row.name, row.name]))
|
||||
return results;
|
||||
export async function fetchChoices(label: string, locale: string) {
|
||||
let choicesById: Record<string, string> = {};
|
||||
const {data} = await run(
|
||||
db
|
||||
.from(label)
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
${label}_translations!left (
|
||||
locale,
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq(`${label}_translations.locale`, locale)
|
||||
.order('name', {ascending: true})
|
||||
)
|
||||
|
||||
data.forEach((row: any) => {
|
||||
const translated = row[`${label}_translations`]?.[0]?.name ?? row.name
|
||||
choicesById[row.id] = translated
|
||||
})
|
||||
|
||||
return choicesById
|
||||
}
|
||||
|
||||
export const useChoices = (label: string) => {
|
||||
const [choices, setChoices] = usePersistentInMemoryState({}, `${label}-choices`)
|
||||
const [choices, setChoices] = usePersistentInMemoryState<Record<string, string>>({}, `${label}-choices`)
|
||||
const {locale} = useLocale()
|
||||
|
||||
const refreshChoices = async () => {
|
||||
try {
|
||||
const results = await fetchChoices(label)
|
||||
const results = await fetchChoices(label, locale)
|
||||
setChoices(results)
|
||||
} catch (err) {
|
||||
console.error('Error fetching choices:', err)
|
||||
@@ -26,7 +46,7 @@ export const useChoices = (label: string) => {
|
||||
useEffect(() => {
|
||||
console.log('Fetching choices in use effect...')
|
||||
refreshChoices()
|
||||
}, [])
|
||||
}, [locale])
|
||||
|
||||
return {choices, refreshChoices}
|
||||
}
|
||||
|
||||
@@ -388,20 +388,6 @@
|
||||
"profile.bio.tips_list": "- Ihre Grundwerte, Interessen und Aktivitäten\n- Persönlichkeitsmerkmale, was Sie einzigartig macht und was Ihnen wichtig ist\n- Verbindungsziele (Zusammenarbeit, Freundschaft, romantisch)\n- Erwartungen und Grenzen\n- Verfügbarkeit, wie man Sie kontaktieren oder ein Gespräch beginnen kann (E-Mail, soziale Netzwerke, etc.)\n- Optional: romantische Vorlieben, Lebensgewohnheiten und Gesprächsthemen",
|
||||
"profile.bio.too_short": "Bio zu kurz. Das Profil kann aus den Suchergebnissen gefiltert werden.",
|
||||
"profile.bio.too_short_tooltip": "Da Ihre Bio zu kurz ist, filtert der Compass-Algorithmus Ihr Profil aus den Suchergebnissen (außer „Kurze Bios einbeziehen“ ist ausgewählt). Dies stellt sicher, dass Suchergebnisse aussagekräftige Profile anzeigen.",
|
||||
"profile.causes.ai_safety": "KI-Sicherheit",
|
||||
"profile.causes.animal_rights": "Tierrechte",
|
||||
"profile.causes.animal_welfare": "Tierschutz",
|
||||
"profile.causes.biodiversity": "Biodiversität",
|
||||
"profile.causes.cause_agnostic": "Ursachenagnostisch",
|
||||
"profile.causes.climate_change": "Klimawandel",
|
||||
"profile.causes.degrowth": "Degrowth",
|
||||
"profile.causes.disaster_relief": "Katastrophenhilfe",
|
||||
"profile.causes.education": "Bildung",
|
||||
"profile.causes.extreme_poverty": "Extreme Armut",
|
||||
"profile.causes.gender_equality": "Gleichstellung der Geschlechter",
|
||||
"profile.causes.human_rights": "Menschenrechte",
|
||||
"profile.causes.mental_health": "Psychische Gesundheit",
|
||||
"profile.causes.sustainability": "Nachhaltigkeit",
|
||||
"profile.comments.current_user_hint": "Andere Benutzer können Ihnen hier Empfehlungen hinterlassen.",
|
||||
"profile.comments.add_comment": "Kommentar hinzufügen",
|
||||
"profile.comments.placeholder": "Schreiben Sie Ihre Empfehlung...",
|
||||
@@ -461,33 +447,6 @@
|
||||
"profile.header.toast.failed_enable": "Aktivierung des Profils fehlgeschlagen",
|
||||
"profile.info.signup_to_see": "Registrieren Sie sich, um das Profil zu sehen",
|
||||
"profile.interested_in": "Interessiert an",
|
||||
"profile.interests.chess": "Schach",
|
||||
"profile.interests.coding": "Programmieren",
|
||||
"profile.interests.collecting": "Sammeln",
|
||||
"profile.interests.cooking": "Kochen",
|
||||
"profile.interests.crafting": "Handwerk",
|
||||
"profile.interests.crochet": "Häkeln",
|
||||
"profile.interests.dancing": "Tanzen",
|
||||
"profile.interests.drawing": "Zeichnen",
|
||||
"profile.interests.game_design": "Spieleentwicklung",
|
||||
"profile.interests.gardening": "Gärtnern",
|
||||
"profile.interests.guitar": "Gitarre",
|
||||
"profile.interests.gym": "Fitnessstudio",
|
||||
"profile.interests.hiking": "Wandern",
|
||||
"profile.interests.meditation": "Meditation",
|
||||
"profile.interests.music_listening": "Musik hören",
|
||||
"profile.interests.music_playing": "Musik spielen",
|
||||
"profile.interests.painting": "Malen",
|
||||
"profile.interests.philosophy": "Philosophie",
|
||||
"profile.interests.photography": "Fotografie",
|
||||
"profile.interests.piano": "Klavier",
|
||||
"profile.interests.pottery": "Töpfern",
|
||||
"profile.interests.reading": "Lesen",
|
||||
"profile.interests.running": "Laufen",
|
||||
"profile.interests.traveling": "Reisen",
|
||||
"profile.interests.video_games": "Videospiele",
|
||||
"profile.interests.writing": "Schreiben",
|
||||
"profile.interests.yoga": "Yoga",
|
||||
"profile.language.akan": "Akan",
|
||||
"profile.language.amharic": "Amharisch",
|
||||
"profile.language.arabic": "Arabisch",
|
||||
@@ -705,34 +664,6 @@
|
||||
"profile.wants_kids_2": "Neutral oder offen für Kinder",
|
||||
"profile.wants_kids_3": "Neigt dazu, Kinder zu wollen",
|
||||
"profile.wants_kids_4": "Möchte Kinder",
|
||||
"profile.work.academia": "Akademischer Bereich",
|
||||
"profile.work.agriculture": "Landwirtschaft",
|
||||
"profile.work.construction": "Baugewerbe",
|
||||
"profile.work.consulting": "Beratung",
|
||||
"profile.work.content_design": "Content-Design",
|
||||
"profile.work.data_science": "Data Science",
|
||||
"profile.work.education": "Bildung",
|
||||
"profile.work.engineering": "Ingenieurwesen",
|
||||
"profile.work.fashion": "Mode",
|
||||
"profile.work.film": "Film",
|
||||
"profile.work.finance": "Finanzen",
|
||||
"profile.work.graphic_design": "Grafikdesign",
|
||||
"profile.work.human_resources": "Personalwesen",
|
||||
"profile.work.it": "IT",
|
||||
"profile.work.journalism": "Journalismus",
|
||||
"profile.work.law": "Recht",
|
||||
"profile.work.management": "Management",
|
||||
"profile.work.marketing": "Marketing",
|
||||
"profile.work.medicine": "Medizin",
|
||||
"profile.work.music": "Musik",
|
||||
"profile.work.nursing": "Krankenpflege",
|
||||
"profile.work.research_and_design": "Forschung und Design",
|
||||
"profile.work.sales": "Vertrieb",
|
||||
"profile.work.social_enterprise": "Sozialunternehmen",
|
||||
"profile.work.social_work": "Soziale Arbeit",
|
||||
"profile.work.spirits_maker": "Spirituosenherstellung",
|
||||
"profile.work.sports": "Sport",
|
||||
"profile.work.tourism": "Tourismus",
|
||||
"profile_grid.no_profiles": "Keine Profile gefunden.",
|
||||
"profile_grid.notification_cta": "Klicken Sie gern auf „Benachrichtigen“, und wir informieren Sie, sobald neue Nutzer Ihrer Suche entsprechen.",
|
||||
"profiles.title": "Personen",
|
||||
|
||||
@@ -388,20 +388,6 @@
|
||||
"profile.bio.tips_list": "- Vos valeurs fondamentales, intérêts et activités\n- Traits de personnalité, ce qui vous rend unique et ce qui vous tient à cœur\n- Objectifs de connexion (collaboration, amitié, romantique)\n- Attentes et limites\n- Disponibilité, comment vous contacter ou démarrer une conversation (e-mail, réseaux sociaux, etc.)\n- Optionnel : préférences romantiques, habitudes de vie et sujets de conversation",
|
||||
"profile.bio.too_short": "Bio trop courte. Le profil peut être filtré des résultats de recherche.",
|
||||
"profile.bio.too_short_tooltip": "Comme votre bio est trop courte, l'algorithme de Compass filtre votre profil des résultats de recherche (sauf si « Inclure les bios courtes » est sélectionné). Cela garantit que les recherches affichent des profils significatifs.",
|
||||
"profile.causes.ai_safety": "Sécurité de l'IA",
|
||||
"profile.causes.animal_rights": "Droits des animaux",
|
||||
"profile.causes.animal_welfare": "Bien-être animal",
|
||||
"profile.causes.biodiversity": "Biodiversité",
|
||||
"profile.causes.cause_agnostic": "Cause agnostique",
|
||||
"profile.causes.climate_change": "Changement climatique",
|
||||
"profile.causes.degrowth": "Décroissance",
|
||||
"profile.causes.disaster_relief": "Secours en cas de catastrophe",
|
||||
"profile.causes.education": "Éducation",
|
||||
"profile.causes.extreme_poverty": "Pauvreté extrême",
|
||||
"profile.causes.gender_equality": "Égalité des genres",
|
||||
"profile.causes.human_rights": "Droits humains",
|
||||
"profile.causes.mental_health": "Santé mentale",
|
||||
"profile.causes.sustainability": "Durabilité",
|
||||
"profile.comments.current_user_hint": "Les autres utilisateurs peuvent vous laisser des recommandations ici.",
|
||||
"profile.comments.add_comment": "Ajouter un commentaire",
|
||||
"profile.comments.placeholder": "Écrivez votre recommandation...",
|
||||
@@ -461,33 +447,6 @@
|
||||
"profile.header.toast.failed_enable": "Échec de l'activation du profil",
|
||||
"profile.info.signup_to_see": "Inscrivez-vous pour voir le profil",
|
||||
"profile.interested_in": "Intéressé·e par",
|
||||
"profile.interests.chess": "Échecs",
|
||||
"profile.interests.coding": "Programmation",
|
||||
"profile.interests.collecting": "Collection",
|
||||
"profile.interests.cooking": "Cuisine",
|
||||
"profile.interests.crafting": "Artisanat",
|
||||
"profile.interests.crochet": "Crochet",
|
||||
"profile.interests.dancing": "Danse",
|
||||
"profile.interests.drawing": "Dessin",
|
||||
"profile.interests.game_design": "Conception de jeux",
|
||||
"profile.interests.gardening": "Jardinage",
|
||||
"profile.interests.guitar": "Guitare",
|
||||
"profile.interests.gym": "Salle de sport",
|
||||
"profile.interests.hiking": "Randonnée",
|
||||
"profile.interests.meditation": "Méditation",
|
||||
"profile.interests.music_listening": "Écoute de musique",
|
||||
"profile.interests.music_playing": "Pratique musicale",
|
||||
"profile.interests.painting": "Peinture",
|
||||
"profile.interests.philosophy": "Philosophie",
|
||||
"profile.interests.photography": "Photographie",
|
||||
"profile.interests.piano": "Piano",
|
||||
"profile.interests.pottery": "Poterie",
|
||||
"profile.interests.reading": "Lecture",
|
||||
"profile.interests.running": "Course à pied",
|
||||
"profile.interests.traveling": "Voyage",
|
||||
"profile.interests.video_games": "Jeux vidéo",
|
||||
"profile.interests.writing": "Écriture",
|
||||
"profile.interests.yoga": "Yoga",
|
||||
"profile.language.akan": "Akan",
|
||||
"profile.language.amharic": "Amharique",
|
||||
"profile.language.arabic": "Arabe",
|
||||
@@ -705,36 +664,8 @@
|
||||
"profile.wants_kids_2": "Neutre ou ouvert·e à avoir des enfants",
|
||||
"profile.wants_kids_3": "Tend à vouloir des enfants",
|
||||
"profile.wants_kids_4": "Veut des enfants",
|
||||
"profile.work.academia": "Monde universitaire",
|
||||
"profile.work.agriculture": "Agriculture",
|
||||
"profile.work.construction": "Construction",
|
||||
"profile.work.consulting": "Conseil",
|
||||
"profile.work.content_design": "Design de contenu",
|
||||
"profile.work.data_science": "Science des données",
|
||||
"profile.work.education": "Éducation",
|
||||
"profile.work.engineering": "Ingénierie",
|
||||
"profile.work.fashion": "Mode",
|
||||
"profile.work.film": "Cinéma",
|
||||
"profile.work.finance": "Finance",
|
||||
"profile.work.graphic_design": "Design graphique",
|
||||
"profile.work.human_resources": "Ressources humaines",
|
||||
"profile.work.it": "Informatique",
|
||||
"profile.work.journalism": "Journalisme",
|
||||
"profile.work.law": "Droit",
|
||||
"profile.work.management": "Gestion",
|
||||
"profile.work.marketing": "Marketing",
|
||||
"profile.work.medicine": "Médecine",
|
||||
"profile.work.music": "Musique",
|
||||
"profile.work.nursing": "Soins infirmiers",
|
||||
"profile.work.research_and_design": "Recherche et conception",
|
||||
"profile.work.sales": "Vente",
|
||||
"profile.work.social_enterprise": "Entreprise sociale",
|
||||
"profile.work.social_work": "Travail social",
|
||||
"profile.work.spirits_maker": "Fabricant de spiritueux",
|
||||
"profile.work.sports": "Sport",
|
||||
"profile.work.tourism": "Tourisme",
|
||||
"profile_grid.no_profiles": "Aucun profil trouvé.",
|
||||
"profile_grid.notification_cta": "N'hésitez pas à cliquer sur \"Être notifié\" et nous vous préviendrons quand de nouveaux utilisateurs correspondront à votre recherche !",
|
||||
"profile_grid.notification_cta": "N'hésitez pas à cliquer sur \"Recevoir notifs\" et nous vous préviendrons quand de nouveaux utilisateurs correspondront à votre recherche !",
|
||||
"profiles.title": "Personnes",
|
||||
"register.agreement.and": " et ",
|
||||
"register.agreement.prefix": "En vous inscrivant, j'accepte les ",
|
||||
@@ -782,7 +713,7 @@
|
||||
"saved_people.list_header": "Voici les personnes que vous avez enregistrées :",
|
||||
"saved_people.title": "Personnes enregistrées",
|
||||
"saved_searches.button": "Recherches enregistrées",
|
||||
"saved_searches.empty_state": "Vous n'avez enregistré aucune recherche. Pour en enregistrer une, cliquez sur Être notifié et nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront.",
|
||||
"saved_searches.empty_state": "Vous n'avez enregistré aucune recherche. Pour en enregistrer une, cliquez sur Recevoir notifs et nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront.",
|
||||
"saved_searches.notification_note": "Nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront à vos recherches ci-dessous.",
|
||||
"saved_searches.title": "Recherches enregistrées",
|
||||
"security.contact.email_button": "E‑mail",
|
||||
|
||||
Reference in New Issue
Block a user