From a242aca0164d396cc2a99faed0135e48dd8454f3 Mon Sep 17 00:00:00 2001 From: ltuffery <123221865+ltuffery@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:52:23 +0200 Subject: [PATCH 1/2] feat: PhotoController && link frontend --- .../Controllers/Profile/ProfileController.php | 4 + api/src/Controllers/User/PhotoController.php | 77 ++++ api/src/Exceptions/PhotoException.php | 11 + api/src/Model/Photo.php | 19 + api/src/Resources/PhotoResource.php | 21 + api/src/Resources/UserResource.php | 3 +- api/src/Services/Photo.php | 10 +- api/src/routes.php | 5 + frontend/src/api/auth.ts | 1 + frontend/src/api/client.ts | 68 ++- frontend/src/api/photos.ts | 23 ++ .../settings/sections/AccountSection.vue | 143 ++++++- .../settings/sections/HistorySection.vue | 163 +++++--- .../settings/sections/ProfileSection.vue | 391 ++++++++++++++++-- frontend/src/composable/usePhotos.ts | 25 ++ frontend/src/composable/useUsers.ts | 4 +- frontend/src/store/useAuthStore.ts | 2 +- 17 files changed, 814 insertions(+), 156 deletions(-) create mode 100644 api/src/Controllers/User/PhotoController.php create mode 100644 api/src/Exceptions/PhotoException.php create mode 100644 api/src/Resources/PhotoResource.php create mode 100644 frontend/src/api/photos.ts create mode 100644 frontend/src/composable/usePhotos.ts diff --git a/api/src/Controllers/Profile/ProfileController.php b/api/src/Controllers/Profile/ProfileController.php index a6589bed..8ccf70d6 100644 --- a/api/src/Controllers/Profile/ProfileController.php +++ b/api/src/Controllers/Profile/ProfileController.php @@ -58,6 +58,10 @@ public function update(): void continue; } + if ($key === 'password') { + $value = password_hash($value, PASSWORD_DEFAULT); + } + $user->{$key} = $value; } diff --git a/api/src/Controllers/User/PhotoController.php b/api/src/Controllers/User/PhotoController.php new file mode 100644 index 00000000..cf21a9b1 --- /dev/null +++ b/api/src/Controllers/User/PhotoController.php @@ -0,0 +1,77 @@ + Flight::user()->id, + ]); + + Flight::json(PhotoResource::collection($photos)); + } + + public function store() + { + /** @var User $user */ + $user = Flight::user(); + $files = Flight::request()->getUploadedFiles(); + $photos = $files['photos'] ?? null; + + if (!$photos) { + Flight::json(['success' => false, 'error' => 'Photos is required.'], 400); + return; + } + if (!is_array($photos)) { + $photos = [$photos]; + } + + if (count($user->getPhotosUrl()) + count($photos) > 5) { + Flight::json(['success' => false, 'error' => 'You cannot upload more than 5 photos.'], 422); + return; + } + + foreach ($photos as $photo) { + if ($photo->getError() !== UPLOAD_ERR_OK) { + Flight::json(['success' => false, 'error' => 'Upload failed (code ' . $photo->getError() . ').'], 422); + return; + } + } + + $p = []; + foreach ($photos as $photo) { + $p[] = Photo::upload($photo); + } + + Flight::json(PhotoResource::collection($p), 201); + } + + + public function delete($id) + { + $photo = Photo::find(['id' => $id]); + + if (is_null($photo)) { + Flight::json([ + 'success' => false, + 'message' => "The photo does not exist.", + ], 404); + return; + } + + unlink(BASE_PATH . "/storage/photos/" . $photo->name . ".png"); + $photo->delete(); + + Flight::json([ + 'success' => true, + 'message' => "The photo has been deleted.", + ], 201); + } +} \ No newline at end of file diff --git a/api/src/Exceptions/PhotoException.php b/api/src/Exceptions/PhotoException.php new file mode 100644 index 00000000..3767102e --- /dev/null +++ b/api/src/Exceptions/PhotoException.php @@ -0,0 +1,11 @@ + $this->user_id, ]); } + + public static function upload(UploadedFile $file): self + { + $service = PhotoService::new($file); + + if (!$service->isValidExtension()) { + throw new PhotoException(); + } + + if (!is_dir(BASE_PATH . "/storage/photos")) { + mkdir(BASE_PATH . "/storage/photos", recursive: true); + } + + return $service->save(Flight::user()); + } } diff --git a/api/src/Resources/PhotoResource.php b/api/src/Resources/PhotoResource.php new file mode 100644 index 00000000..255b6778 --- /dev/null +++ b/api/src/Resources/PhotoResource.php @@ -0,0 +1,21 @@ + $this->model->id, + 'name' => $this->model->name, + ]; + } +} \ No newline at end of file diff --git a/api/src/Resources/UserResource.php b/api/src/Resources/UserResource.php index c256d730..3028f0b4 100644 --- a/api/src/Resources/UserResource.php +++ b/api/src/Resources/UserResource.php @@ -20,8 +20,6 @@ public function jsonSerialize(): mixed 'password', 'email_verified', 'temporary_email_token', - 'lat', - 'lon', ]; $data = $this->model->getData(); @@ -32,6 +30,7 @@ public function jsonSerialize(): mixed $data['avatar'] = $this->model->getAvatar(); $data['id'] = $this->model->id; $data['complete_profile'] = $this->model->hasCompleteProfile(); + $data['verified'] = $this->model->email_verified; return $data; } diff --git a/api/src/Services/Photo.php b/api/src/Services/Photo.php index c5bf8588..3cca6649 100644 --- a/api/src/Services/Photo.php +++ b/api/src/Services/Photo.php @@ -5,6 +5,7 @@ use Exception; use flight\net\UploadedFile; use Matcha\Api\Model\User; +use RuntimeException; class Photo { @@ -38,7 +39,7 @@ private function testCreateByGD(string $extension): bool }; } - public function save(User $user): void + public function save(User $user): \Matcha\Api\Model\Photo { try { $name = bin2hex(random_bytes(15)); @@ -51,7 +52,10 @@ public function save(User $user): void $photo->name = $name; $photo->user_id = $user->id; - $photo->save(); - $this->file->moveTo(BASE_PATH . "/storage/photos/" . $name . ".png"); + if (!move_uploaded_file($this->file->getTempName(), BASE_PATH . "/storage/photos/" . $name . ".png")) { + throw new RuntimeException('Failed to store photo.'); + } + + return $photo->save(); } } diff --git a/api/src/routes.php b/api/src/routes.php index 09935690..c8d3e430 100644 --- a/api/src/routes.php +++ b/api/src/routes.php @@ -18,6 +18,7 @@ use Matcha\Api\Controllers\RegisterController; use Matcha\Api\Controllers\SearchProfileController; use Matcha\Api\Controllers\TagsController; +use Matcha\Api\Controllers\User\PhotoController; use Matcha\Api\Controllers\ViewController; use Matcha\Api\Middleware\AuthMiddleware; use Matcha\Api\Middleware\CompleteProfileMiddleware; @@ -92,6 +93,10 @@ Flight::route('POST /notifications/@id/view', [NotificationsController::class, 'viewed']); Flight::route('GET /notifications', [NotificationsController::class, 'index']); + Flight::route('POST /photos', [PhotoController::class, 'store']); + Flight::route('GET /photos', [PhotoController::class, 'index']); + Flight::route('DELETE /photos/@id', [PhotoController::class, 'delete']); + Flight::group('/matches', function () { Flight::route('GET /', [ChatController::class, 'index']); Flight::route('GET /@username', [ChatController::class, 'show']); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 1869ac4c..aa050434 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -29,6 +29,7 @@ export interface User { created_at: string last_connection: string profile_complete: boolean + verified: boolean } export interface LoginCredentials { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3c56bed7..08a499df 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,13 +2,16 @@ import { useAuth } from '@/composable/useAuth' import { ApiError, type ApiErrorBody, ApiErrorCode } from '@/api/errors' import { useAuthStore } from '@/store/useAuthStore' +type Body = Record | FormData | null + export class ApiClient { method = '' path = '' headers: Record = { 'Content-Type': 'application/json', } - private refreshPromise: Promise | null = null + private retried = false + private static refreshPromise: Promise | null = null static get( path: string, @@ -24,10 +27,11 @@ export class ApiClient { return this.request('DELETE', path, body) } - static post( - path: string, - body: Record | null = null, - ): Promise> { + post(path: string, body: Body = null): Promise> { + return this.request('POST', path, body) + } + + static post(path: string, body: Body = null): Promise> { return this.request('POST', path, body) } @@ -38,6 +42,17 @@ export class ApiClient { return this.request('PUT', path, body) } + async request( + method: string, + path: string, + body: Body = null, + ): Promise> { + this.method = method + this.path = path.replace(/^\/+|\/+$/g, '') + + return await this.send(body) + } + static async request( method: string, path: string, @@ -45,10 +60,7 @@ export class ApiClient { ): Promise> { const self = new this() - self.method = method - self.path = path.replace(/^\/+|\/+$/g, '') - - return await self.send(body) + return self.request(method, path, body) } header(name: string, value: string): this { @@ -57,9 +69,14 @@ export class ApiClient { return this } - private async send( - body: Record | null = null, - ): Promise> { + static header(name: string, value: string): ApiClient { + const self = new this() + self.headers[name] = value + + return self + } + + private async send(body: Body = null): Promise> { const jwt = localStorage.getItem('jwt') if (jwt != null) { @@ -74,20 +91,33 @@ export class ApiClient { if (query) url += `?${query}` } + const headers = { ...this.headers } + let payload: BodyInit | null = null + + if (body != null && this.method !== 'GET') { + if (body instanceof FormData) { + delete headers['Content-Type'] + payload = body + } else { + payload = JSON.stringify(body) + } + } + const res = await fetch(url, { method: this.method, - headers: this.headers, - body: body != null && this.method !== 'GET' ? JSON.stringify(body) : null, + headers: headers, + body: payload, }) - if (res.status === 401 && this.path !== 'auth/refresh') { - this.refreshPromise ??= useAuth() + if (res.status === 401 && this.path !== 'auth/refresh' && !this.retried) { + this.retried = true + ApiClient.refreshPromise ??= useAuth() .refreshSession() .finally(() => { - this.refreshPromise = null + ApiClient.refreshPromise = null }) - await this.refreshPromise - return await this.send(body) + const ok = await ApiClient.refreshPromise + if (ok) return await this.send(body) } if (res.status === 403) { diff --git a/frontend/src/api/photos.ts b/frontend/src/api/photos.ts new file mode 100644 index 00000000..a076ac37 --- /dev/null +++ b/frontend/src/api/photos.ts @@ -0,0 +1,23 @@ +import { ApiClient } from '@/api/client' + +export interface Photo { + id: number + name: string +} + +export const photosApi = { + async getPhotos() { + return ApiClient.get('/users/me/photos').then(res => res.json()) + }, + + async uploadPhoto(fd: FormData) { + return ApiClient.post( + '/users/me/photos', + fd, + ).then(res => res.json()) + }, + + async deletePhoto(id: number) { + return ApiClient.delete(`/users/me/photos/${id}`).then(res => res.json()) + } +} diff --git a/frontend/src/components/settings/sections/AccountSection.vue b/frontend/src/components/settings/sections/AccountSection.vue index 241817c4..31c2d017 100644 --- a/frontend/src/components/settings/sections/AccountSection.vue +++ b/frontend/src/components/settings/sections/AccountSection.vue @@ -1,6 +1,10 @@ diff --git a/frontend/src/components/settings/sections/HistorySection.vue b/frontend/src/components/settings/sections/HistorySection.vue index 943384d1..dd5ba498 100644 --- a/frontend/src/components/settings/sections/HistorySection.vue +++ b/frontend/src/components/settings/sections/HistorySection.vue @@ -7,6 +7,13 @@ import { Skeleton } from '@/components/ui/skeleton' import { Heart, Eye } from 'lucide-vue-next' import UserItem from '@/components/users/UserItem.vue' import { useMe } from '@/composable/useMe.ts' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card/index.ts' const me = useMe() @@ -41,73 +48,93 @@ onMounted(async () => { diff --git a/frontend/src/components/settings/sections/ProfileSection.vue b/frontend/src/components/settings/sections/ProfileSection.vue index 2a0741ee..73b13aa4 100644 --- a/frontend/src/components/settings/sections/ProfileSection.vue +++ b/frontend/src/components/settings/sections/ProfileSection.vue @@ -1,6 +1,19 @@