diff --git a/Makefile b/Makefile index 66a47df3..9ed971ad 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ DC := docker compose -all: set_app_host up +all: up up: $(DC) up --build @@ -26,4 +26,4 @@ set_app_host: echo "\nAPP_HOST=$(shell hostname -i)" >> .env; \ fi -.PHONY: all re clean fclean up down \ No newline at end of file +.PHONY: all re clean fclean up down diff --git a/api/database/migrations/016_create_table_passes.sql b/api/database/migrations/016_create_table_passes.sql new file mode 100644 index 00000000..46828efb --- /dev/null +++ b/api/database/migrations/016_create_table_passes.sql @@ -0,0 +1,13 @@ +CREATE TABLE passes ( + user_id INT NOT NULL, + target_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (user_id, target_id), + + CONSTRAINT passes_user_fk + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT passes_target_fk + FOREIGN KEY (target_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT passes_no_self_pass CHECK (user_id <> target_id) +); diff --git a/api/database/migrations/017_alter_preferences.sql b/api/database/migrations/017_alter_preferences.sql new file mode 100644 index 00000000..fc4b911f --- /dev/null +++ b/api/database/migrations/017_alter_preferences.sql @@ -0,0 +1,3 @@ +ALTER TABLE `preferences` + ADD `fame_gap` INTEGER DEFAULT 0, + DROP COLUMN `is_custom_loc`; diff --git a/api/src/Controllers/LikeController.php b/api/src/Controllers/LikeController.php index f91c5b7e..4d8c0bc7 100644 --- a/api/src/Controllers/LikeController.php +++ b/api/src/Controllers/LikeController.php @@ -9,26 +9,22 @@ class LikeController { public function store(string $username) { - $user = User::find([ - 'username' => $username, - ]); - - if (is_null($user)) { - Flight::json([ - 'message' => 'User not found', - ], 404); + $target = User::find(['username' => $username]); + $me = Flight::user(); + if (is_null($target) || $target->isBlocking($me)) { + Flight::json(['message' => 'User not found'], 404); return; } - Flight::user()->like($user); + Flight::user()->like($target); - Flight::notifier()->publish("user/" . $user->id . "/notifications", [ + Flight::notifier()->publish("user/" . $target->id . "/notifications", [ 'type' => 'like', - 'message' => Flight::user()->username . " a aimé ton profil", + 'message' => Flight::user()->username . " has liked your profile", ], private: false); - Flight::json([], 203); + Flight::json([], 201); } public function destroy(string $username) diff --git a/api/src/Controllers/PassController.php b/api/src/Controllers/PassController.php new file mode 100644 index 00000000..34e03a29 --- /dev/null +++ b/api/src/Controllers/PassController.php @@ -0,0 +1,26 @@ + $username]); + + if (is_null($target)) { + Flight::json([ + 'success' => false, + 'message' => 'User not found' + ], 404); + return; + } + + Flight::user()->pass($target); + + Flight::json(['success' => true,], 204); + } +} diff --git a/api/src/Controllers/Profile/ProfileSuggestionController.php b/api/src/Controllers/Profile/ProfileSuggestionController.php deleted file mode 100644 index 4fda2521..00000000 --- a/api/src/Controllers/Profile/ProfileSuggestionController.php +++ /dev/null @@ -1,74 +0,0 @@ -getSuggestions($user); - - $preferences = $user->getPreferences(); - - - $users = array_filter($users, function ($value) use ($user, $preferences) { - $userPreferences = $value->getPreferences(); - return $this->inLocation($preferences->lat, $preferences->lon, $userPreferences->lat, $userPreferences->lon, $preferences->distance_maximum) - && !$user->isBlocking($value); - }); - - $tags = $user->getTags(); - - usort($users, function (User $a, User $b) use ($tags) { - return count(array_diff($tags, $b->getTags())) - count(array_diff($tags, $a->getTags())); - }); - - Flight::json( - ProfileResource::collection($users) - ); - } - - /** - * @return User[] - * @throws ReflectionException - */ - private function getSuggestions(User $user): array - { - $preferences = $user->getPreferences(); - - $sexualPreference = $preferences->sexual_preferences == 'A' ? - "users.gender IN ('M', 'F', 'O')" : - "users.gender = '$preferences->sexual_preferences'"; - - $users = User::where([ - ['users.gender', 'IN', "('M', 'F', 'O')"] - ]) - ->join('preferences', function (JoinBuilder $builder) use ($user) { - $builder - ->and('preferences.user_id', '=', 'users.id') - ->and('preferences.sexual_preferences', 'IN', "('A', '$user->gender')"); - }) - ->join('likes', function (JoinBuilder $builder) { - $builder->and('likes.user_id', '=', 'users.id'); - }) - ->groupBy('users.username'); - - $users = $users->get(true); - - return $users; - } - - private function inLocation($lat1, $lon1, $lat2, $lon2, $rayon): bool - { - return haversine($lat1, $lon1, $lat2, $lon2) <= $rayon; - } -} diff --git a/api/src/Controllers/SuggestionController.php b/api/src/Controllers/SuggestionController.php new file mode 100644 index 00000000..20c692d0 --- /dev/null +++ b/api/src/Controllers/SuggestionController.php @@ -0,0 +1,45 @@ +query; + + $sort = $query['sort'] ?? null; + $order = strtolower($query['order'] ?? 'desc'); + $offset = (int) ($query['offset'] ?? 0); + $limit = (int) ($query['limit'] ?? 20); + + if ($sort !== null && !in_array($sort, self::SORTS, true)) { + Flight::json(['message' => 'Invalid sort'], 422); + return; + } + + if (!in_array($order, ['asc', 'desc'], true)) { + Flight::json(['message' => 'Invalid order'], 422); + return; + } + + if ($offset < 0 || $limit < 1 || $limit > self::MAX_LIMIT) { + Flight::json(['message' => 'Invalid pagination'], 422); + return; + } + + $result = Flight::user()->suggestions($sort, $order, $offset, $limit); + + Flight::json([ + 'count' => count($result['profiles']), + 'has_more' => $result['has_more'], + 'profiles' => $result['profiles'], + ]); + } +} diff --git a/api/src/Exceptions/AutoPassException.php b/api/src/Exceptions/AutoPassException.php new file mode 100644 index 00000000..b161eb72 --- /dev/null +++ b/api/src/Exceptions/AutoPassException.php @@ -0,0 +1,15 @@ + (bool)rand(0, 1), 'lon' => 10, 'lat' => 10, + 'fame_gap' => 10, ]; } } diff --git a/api/src/Model/Pass.php b/api/src/Model/Pass.php new file mode 100644 index 00000000..721e6e7d --- /dev/null +++ b/api/src/Model/Pass.php @@ -0,0 +1,26 @@ + $this->target_id]); + } + + public function user(): User + { + return User::find(['id' => $this->user_id]); + } +} diff --git a/api/src/Model/Preference.php b/api/src/Model/Preference.php index b5fd8aa1..88e26fcd 100644 --- a/api/src/Model/Preference.php +++ b/api/src/Model/Preference.php @@ -32,7 +32,7 @@ class Preference extends Model public float $lon = 0; - public bool $is_custom_loc = false; + public int $fame_gap = 10; public function user(): User { diff --git a/api/src/Model/User.php b/api/src/Model/User.php index 3a25f06a..e3c1ee8e 100644 --- a/api/src/Model/User.php +++ b/api/src/Model/User.php @@ -6,11 +6,14 @@ use Flight; use Matcha\Api\Builder\JoinBuilder; use Matcha\Api\Exceptions\AutoLikeException; +use Matcha\Api\Exceptions\AutoPassException; +use Matcha\Api\Resources\ProfileResource; use Matcha\Api\Validator\Asserts\Email; use Matcha\Api\Validator\Asserts\Minimum; use Matcha\Api\Validator\Asserts\NotBlank; use Matcha\Api\Validator\Asserts\Regex; use PDO; +use DateTimeImmutable; /** * @method static User find(array $data) @@ -102,7 +105,26 @@ public function getAvatar(): ?string return null; } - return "https://" . trim(getenv('APP_HOST') ?? 'localhost', '/') . "/api/medias/p/" . $photo->name; + return "http://" . trim(getenv('APP_HOST') ?? 'localhost', '/') . ":8011/api/medias/p/" . $photo->name; + } + + /** + * Create a pass from the user to the user passed as a parameter + * + * @param User $user + * @return void + */ + public function pass(User $user): void + { + if ($this->id === $user->id) { + throw new AutoPassException(); + } + + $pass = new Pass(); + $pass->user_id = $this->id; + $pass->target_id = $user->id; + + $pass->save(); } /** @@ -246,16 +268,45 @@ public function getPhotosUrl(): array $host = getenv('APP_HOST') ?: "localhost"; - return array_map(fn (Photo $photo) => "https://" . trim($host, '/') . "/api/medias/p/" . $photo->name, $photos); + return array_map(fn (Photo $photo) => "http://" . trim($host, '/') . ":8011/api/medias/p/" . $photo->name, $photos); } + public function getAge(): ?int { - $birthDate = explode("-", $this->birthday); + if (empty($this->birthday)) { + return null; + } - return (date("md", date("U", mktime(0, 0, 0, $birthDate[2], $birthDate[1], $birthDate[0]))) > date("md") - ? ((date("Y") - $birthDate[0]) - 1) - : (date("Y") - $birthDate[0])); + try { + $birthDate = DateTimeImmutable::createFromFormat( + '!Y-m-d', + $this->birthday + ); + + $errors = DateTimeImmutable::getLastErrors(); + + if ( + $birthDate === false + || ($errors !== false && ( + $errors['warning_count'] > 0 + || $errors['error_count'] > 0 + )) + || $birthDate->format('Y-m-d') !== $this->birthday + ) { + return null; + } + + $today = new DateTimeImmutable('today'); + + if ($birthDate > $today) { + return null; + } + + return $birthDate->diff($today)->y; + } catch (Throwable) { + return null; + } } /** @@ -277,7 +328,7 @@ public function removeTag(string $name): void { $stmt = Flight::db()->prepare(" DELETE FROM user_tags - WHERE `user_id` = :user_id + WHERE `user_id` = :user_id AND `tag_id` = (SELECT tags.id FROM tags WHERE tags.name = :name); "); @@ -372,4 +423,123 @@ public static function authenticate(string $username, string $password): User|fa return false; } + /** + * Suggested profiles for this user, based on their stored preferences. + * + * @param string|null $sort distance|fame|common_tags|age, null for the default order + * @param string $order asc|desc (ignored when $sort is null) + * @return array + */ + public function suggestions(?string $sort, string $order, int $offset, int $limit): array + { + $prefs = Preference::find(['user_id' => $this->id]); + + // --- Paramètres calculés une seule fois --- + $distMax = (int) $prefs->distance_maximum; + $lat = (float) $prefs->lat; + $lon = (float) $prefs->lon; + + $today = new DateTimeImmutable('today'); + $ageMin = (int) ($prefs->age_minimum ?? 18); + $ageMax = (int) ($prefs->age_maximum ?? 100); + $birthMax = $today->modify("-{$ageMin} years")->format('Y-m-d'); + $birthMin = $today->modify('-' . ($ageMax + 1) . ' years')->format('Y-m-d'); + + $deltaLat = $distMax / 111.2; + $deltaLon = $distMax / (111.2 * max(cos(deg2rad($lat)), 0.01)); + + // fame_gap = 0 signifie "désactivé" + $fameGap = (int) $prefs->fame_gap > 0 ? (int) $prefs->fame_gap : 1000000; + + $fetch = $limit + 1; + + $dir = $order === 'asc' ? 'ASC' : 'DESC'; + $orderBy = match ($sort) { + 'distance' => "distance_km $dir, id ASC", + 'fame' => "fame_rating $dir, id ASC", + 'common_tags' => "common_tags $dir, id ASC", + 'age' => 'birthday ' . ($order === 'asc' ? 'DESC' : 'ASC') . ', id ASC', + default => $prefs->by_tags + ? 'tier ASC, common_tags DESC, fame_rating DESC, id ASC' + : 'tier ASC, fame_rating DESC, id ASC', + }; + + $sql = " + WITH candidates AS ( + SELECT + u.*, + ST_Distance_Sphere(POINT(p.lon, p.lat), POINT(:me_lon, :me_lat)) / 1000 AS distance_km, + (SELECT COUNT(*) FROM user_tags ut + JOIN user_tags mine ON mine.tag_id = ut.tag_id AND mine.user_id = :me_id_tags + WHERE ut.user_id = u.id) AS common_tags + FROM users u + JOIN preferences p ON p.user_id = u.id + WHERE u.id <> :me_id + AND u.email_verified = 1 + AND (:me_pref = 'A' OR :me_pref2 = u.gender) + AND (p.sexual_preferences = 'A' OR p.sexual_preferences = :me_gender) + AND u.birthday <= :birth_max + AND u.birthday > :birth_min + AND p.lat BETWEEN :lat_min AND :lat_max + AND p.lon BETWEEN :lon_min AND :lon_max + AND ABS(u.fame_rating - :me_fame) <= :fame_gap + AND NOT EXISTS (SELECT 1 FROM likes l + WHERE l.user_id = :me_id_l AND l.liked_id = u.id) + AND NOT EXISTS (SELECT 1 FROM passes ps + WHERE ps.user_id = :me_id_p AND ps.target_id = u.id + AND ps.created_at > NOW() - INTERVAL 7 DAY) + AND NOT EXISTS (SELECT 1 FROM user_blocked b + WHERE (b.user_id = :me_id_b1 AND b.blocked_id = u.id) + OR (b.user_id = u.id AND b.blocked_id = :me_id_b2)) + ) + SELECT + *, + ROUND(distance_km) AS distance_km, + CASE WHEN distance_km < 10 THEN 0 + WHEN distance_km < 50 THEN 1 + ELSE 2 END AS tier + FROM candidates + WHERE distance_km <= :dist_max + ORDER BY $orderBy + LIMIT $fetch OFFSET $offset + "; + + $stmt = Flight::db()->prepare($sql); + $stmt->execute([ + 'me_lon' => $lon, + 'me_lat' => $lat, + 'me_id_tags' => $this->id, + 'me_id' => $this->id, + 'me_pref' => $prefs->sexual_preferences, + 'me_pref2' => $prefs->sexual_preferences, + 'me_gender' => $this->gender, + 'birth_max' => $birthMax, + 'birth_min' => $birthMin, + 'lat_min' => $lat - $deltaLat, + 'lat_max' => $lat + $deltaLat, + 'lon_min' => $lon - $deltaLon, + 'lon_max' => $lon + $deltaLon, + 'me_fame' => $this->fame_rating, + 'fame_gap' => $fameGap, + 'me_id_l' => $this->id, + 'me_id_p' => $this->id, + 'me_id_b1' => $this->id, + 'me_id_b2' => $this->id, + 'dist_max' => $distMax, + ]); + + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + + if (empty($rows)) { + return ['profiles' => [], 'has_more' => false]; + } + + $hasMore = count($rows) > $limit; + $rows = array_slice($rows, 0, $limit); + + $rows = array_map(fn ($r) => User::morph($r), $rows); + + return ['profiles' => ProfileResource::collection($rows), 'has_more' => $hasMore]; + } } diff --git a/api/src/Resources/PreferencesResource.php b/api/src/Resources/PreferencesResource.php index d667fc2e..9e9cab01 100644 --- a/api/src/Resources/PreferencesResource.php +++ b/api/src/Resources/PreferencesResource.php @@ -25,7 +25,7 @@ public function jsonSerialize(): array 'sexual_preferences' => $this->model->sexual_preferences, 'lat' => $this->model->lat, 'lon' => $this->model->lon, - 'is_custom_loc' => $this->model->is_custom_loc, + 'fame_gap' => $this->model->fame_gap, ]; } } diff --git a/api/src/routes.php b/api/src/routes.php index 09935690..b7f26206 100644 --- a/api/src/routes.php +++ b/api/src/routes.php @@ -7,11 +7,12 @@ use Matcha\Api\Controllers\ForgotController; use Matcha\Api\Controllers\HistoryController; use Matcha\Api\Controllers\LikeController; +use Matcha\Api\Controllers\PassController; use Matcha\Api\Controllers\LocalisationController; use Matcha\Api\Controllers\Notifications\NotificationsController; use Matcha\Api\Controllers\Profile\PreferencesController; use Matcha\Api\Controllers\Profile\ProfileController; -use Matcha\Api\Controllers\Profile\ProfileSuggestionController; +use Matcha\Api\Controllers\SuggestionController; use Matcha\Api\Controllers\Profile\ReportController; use Matcha\Api\Controllers\Profile\UserBlockController; use Matcha\Api\Controllers\RefreshTokenController; @@ -66,6 +67,8 @@ Flight::route('POST /like', [LikeController::class, 'store']); Flight::route('DELETE /unlike', [LikeController::class, 'destroy']); + Flight::route('POST /pass', [PassController::class, 'store']); + Flight::route('POST /block', [UserBlockController::class, 'store']); Flight::route('DELETE /unblock', [UserBlockController::class, 'destroy']); @@ -86,7 +89,7 @@ Flight::route('GET /preferences', [PreferencesController::class, 'index']); Flight::route('GET /history', [HistoryController::class, 'index']); - Flight::route('GET /suggestions', [ProfileSuggestionController::class, 'index']); + Flight::route('GET /suggestions', [SuggestionController::class, 'index']); Flight::route('GET /blocks', [UserBlockController::class, 'index']); Flight::route('POST /notifications/@id/view', [NotificationsController::class, 'viewed']); diff --git a/api/tests/Controllers/LikeControllerTest.php b/api/tests/Controllers/LikeControllerTest.php index 74228725..6bbea407 100644 --- a/api/tests/Controllers/LikeControllerTest.php +++ b/api/tests/Controllers/LikeControllerTest.php @@ -44,7 +44,7 @@ public function testLikeUserFound(): void 'Authorization' => 'Bearer ' . $this->user->generateJWT(), ])->post('/users/' . $liked->username . '/like'); - $response->assertStatus(203); + $response->assertStatus(201); } public function testUnLikeUserNotFound(): void @@ -92,7 +92,7 @@ public function testDoubleLikeUser(): void 'Authorization' => 'Bearer ' . $this->user->generateJWT(), ])->post('/users/' . $liked->username . '/like'); - $response->assertStatus(203); + $response->assertStatus(201); $this->expectException(UniqueConstraintException::class); @@ -112,9 +112,9 @@ public function testLikeMultipleUser(): void 'Authorization' => 'Bearer ' . $this->user->generateJWT(), ])->post('/users/' . $user->username . '/like'); - $response->assertStatus(203); + $response->assertStatus(201); } $this->assertCount(5, $this->user->likes()); } -} \ No newline at end of file +} diff --git a/api/tests/Controllers/RegisterControllerTest.php b/api/tests/Controllers/RegisterControllerTest.php index 987e0495..7a6cb5a7 100644 --- a/api/tests/Controllers/RegisterControllerTest.php +++ b/api/tests/Controllers/RegisterControllerTest.php @@ -143,7 +143,7 @@ public function testPreferencesIsCreated(): void $this->assertNotNull($preferences); $this->assertEquals('A', $preferences->sexual_preferences); $this->assertEquals(18, $preferences->age_minimum); - $this->assertEquals(24, $preferences->age_maximum); + $this->assertEquals(23, $preferences->age_maximum); $this->assertTrue($preferences->by_tags); } -} \ No newline at end of file +} diff --git a/frontend/src/api/preferences.ts b/frontend/src/api/preferences.ts index 09c67676..03e3f169 100644 --- a/frontend/src/api/preferences.ts +++ b/frontend/src/api/preferences.ts @@ -10,7 +10,7 @@ export interface UserPreferences { sexual_preferences: SexualPreference lat: number lon: number - is_custom_loc: boolean + fame_gap: number } export const apiPreferences = { diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 0a39bfec..bf193b56 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -22,6 +22,19 @@ export interface Profile { email?: string } +export interface SuggestionParams { + sort?: string + order?: string + offset?: number + limit?: number +} + +export interface SuggestionProfile { + profiles: Profile[] + count: number + has_more: boolean +} + export const usersApi = { // --- View --- async getProfile(username: string) { @@ -61,4 +74,16 @@ export const usersApi = { res.json(), ) }, + + async suggestion(params: SuggestionParams) { + return ApiClient.get(`/users/me/suggestions`, params).then(res => + res.json(), + ) + }, + + async pass(username: string) { + return ApiClient.post(`/users/${username}/pass`).then(res => + res.json(), + ) + }, } diff --git a/frontend/src/components/MainUser.vue b/frontend/src/components/MainUser.vue index ffed5298..b4f41a9a 100644 --- a/frontend/src/components/MainUser.vue +++ b/frontend/src/components/MainUser.vue @@ -113,6 +113,8 @@ function btnLike() { :src="image" alt="Photo de profil" class="h-full w-full object-cover rounded-lg" + loading="lazy" + decoding="async" v-double-tap="userLike" /> diff --git a/frontend/src/components/SortDropdown.vue b/frontend/src/components/SortDropdown.vue new file mode 100644 index 00000000..c8f3e4dc --- /dev/null +++ b/frontend/src/components/SortDropdown.vue @@ -0,0 +1,164 @@ + + + diff --git a/frontend/src/components/settings/sections/MatchingPreferencesSection.vue b/frontend/src/components/settings/sections/MatchingPreferencesSection.vue index c924e6a9..ce1f6f16 100644 --- a/frontend/src/components/settings/sections/MatchingPreferencesSection.vue +++ b/frontend/src/components/settings/sections/MatchingPreferencesSection.vue @@ -38,7 +38,7 @@ const pref = ref({ sexual_preferences: 'A', lat: 50, lon: 11, - is_custom_loc: false, + fame_gap: 10, }) const distanceRange = computed({ @@ -71,7 +71,10 @@ const locations = computed({ }, }) const city = ref('') -const fameRatingRange = ref([100]) +const fameRatingRange = computed({ + get: () => [pref.value.fame_gap], + set: ([v]) => (pref.value.fame_gap = v), +}) const byTags = computed({ get: () => pref.value.by_tags, set: v => { @@ -170,7 +173,7 @@ async function save() {
- +
diff --git a/frontend/src/components/ui/toggle-group/ToggleGroup.vue b/frontend/src/components/ui/toggle-group/ToggleGroup.vue new file mode 100644 index 00000000..b2e3a3d0 --- /dev/null +++ b/frontend/src/components/ui/toggle-group/ToggleGroup.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/ui/toggle-group/ToggleGroupItem.vue b/frontend/src/components/ui/toggle-group/ToggleGroupItem.vue new file mode 100644 index 00000000..e94ed782 --- /dev/null +++ b/frontend/src/components/ui/toggle-group/ToggleGroupItem.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/components/ui/toggle-group/index.ts b/frontend/src/components/ui/toggle-group/index.ts new file mode 100644 index 00000000..9f89ad77 --- /dev/null +++ b/frontend/src/components/ui/toggle-group/index.ts @@ -0,0 +1,2 @@ +export { default as ToggleGroup } from './ToggleGroup.vue' +export { default as ToggleGroupItem } from './ToggleGroupItem.vue' diff --git a/frontend/src/components/ui/toggle/Toggle.vue b/frontend/src/components/ui/toggle/Toggle.vue new file mode 100644 index 00000000..84ef3c5e --- /dev/null +++ b/frontend/src/components/ui/toggle/Toggle.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/components/ui/toggle/index.ts b/frontend/src/components/ui/toggle/index.ts new file mode 100644 index 00000000..714d2e09 --- /dev/null +++ b/frontend/src/components/ui/toggle/index.ts @@ -0,0 +1,27 @@ +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' + +export { default as Toggle } from './Toggle.vue' + +export const toggleVariants = cva( + 'hover:text-foreground aria-pressed:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive gap-1 rounded-md text-sm font-medium transition-[color,box-shadow] [&_svg:not([class*=size-])]:size-4 group/toggle hover:bg-muted inline-flex items-center justify-center whitespace-nowrap outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-transparent', + outline: 'border-input hover:bg-muted border bg-transparent shadow-xs', + }, + size: { + default: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + sm: 'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5', + lg: 'h-10 min-w-10 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export type ToggleVariants = VariantProps diff --git a/frontend/src/composable/useUsers.ts b/frontend/src/composable/useUsers.ts index b9fe64af..c663e5d2 100644 --- a/frontend/src/composable/useUsers.ts +++ b/frontend/src/composable/useUsers.ts @@ -1,5 +1,6 @@ import { ref } from 'vue' import { usersApi } from '@/api/users' +import type { Profile, SuggestionParams } from '@/api/users' export function useUsers() { const isLoading = ref(false) @@ -7,6 +8,8 @@ export function useUsers() { const userProfile = ref(null) const blockedUsers = ref([]) + const hasMore = ref(true) + const suggestedProfiles = ref([]) // --- Consultation --- const fetchProfile = async (username: string) => { @@ -120,12 +123,50 @@ export function useUsers() { } } + const suggestion = async (params: SuggestionParams) => { + // if (isLoading.value) return + if (!hasMore.value) return + isLoading.value = true + error.value = null + try { + const data = await usersApi.suggestion(params) + const known = new Set(suggestedProfiles.value.map(p => p.username)) + suggestedProfiles.value.push(...data.profiles.filter((p) => !known.has(p.username))) + hasMore.value = data.has_more; + } catch (err: any) { + error.value = + err.response?.data?.message || 'suggestion error' + } finally { + isLoading.value = false + } + } + + const suggestionReset = () => { + suggestedProfiles.value = [] + } + + const pass = async (username: string) => { + if (isLoading.value) return + isLoading.value = true + error.value = null + try { + await usersApi.pass(username) + } catch (err: any) { + error.value = + err.response?.data?.message || 'pass error' + } finally { + isLoading.value = false + } + } + return { // États réactifs isLoading, error, userProfile, blockedUsers, + suggestedProfiles, + hasMore, // Méthodes fetchProfile, @@ -136,5 +177,8 @@ export function useUsers() { block, unblock, report, + suggestion, + suggestionReset, + pass, } } diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 3def97db..10e90dae 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,56 +1,28 @@