├── backend ├── uploads │ └── .gitkeep ├── settings.gradle.kts ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── .gitignore ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── linkedin │ │ │ │ └── backend │ │ │ │ ├── dto │ │ │ │ └── Response.java │ │ │ │ ├── features │ │ │ │ ├── messaging │ │ │ │ │ ├── dto │ │ │ │ │ │ └── MessageDto.java │ │ │ │ │ ├── repository │ │ │ │ │ │ ├── MessageRepository.java │ │ │ │ │ │ └── ConversationRepository.java │ │ │ │ │ ├── model │ │ │ │ │ │ ├── Conversation.java │ │ │ │ │ │ └── Message.java │ │ │ │ │ └── controller │ │ │ │ │ │ └── MessagingController.java │ │ │ │ ├── networking │ │ │ │ │ ├── model │ │ │ │ │ │ ├── Status.java │ │ │ │ │ │ └── Connection.java │ │ │ │ │ ├── repository │ │ │ │ │ │ └── ConnectionRepository.java │ │ │ │ │ └── controller │ │ │ │ │ │ └── ConnectionController.java │ │ │ │ ├── notifications │ │ │ │ │ ├── model │ │ │ │ │ │ ├── NotificationType.java │ │ │ │ │ │ └── Notification.java │ │ │ │ │ ├── repository │ │ │ │ │ │ └── NotificationRepository.java │ │ │ │ │ └── controller │ │ │ │ │ │ └── NotificationsController.java │ │ │ │ ├── authentication │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── AuthenticationResponseBody.java │ │ │ │ │ │ ├── AuthenticationOauthRequestBody.java │ │ │ │ │ │ └── AuthenticationRequestBody.java │ │ │ │ │ ├── repository │ │ │ │ │ │ └── UserRepository.java │ │ │ │ │ ├── utils │ │ │ │ │ │ ├── Encoder.java │ │ │ │ │ │ └── EmailService.java │ │ │ │ │ ├── configuration │ │ │ │ │ │ └── AuthenticationConfiguration.java │ │ │ │ │ └── filter │ │ │ │ │ │ └── AuthenticationFilter.java │ │ │ │ ├── feed │ │ │ │ │ ├── repository │ │ │ │ │ │ ├── CommentRepository.java │ │ │ │ │ │ └── PostRepository.java │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── CommentDto.java │ │ │ │ │ │ └── PostDto.java │ │ │ │ │ └── model │ │ │ │ │ │ ├── Comment.java │ │ │ │ │ │ └── Post.java │ │ │ │ ├── search │ │ │ │ │ ├── controller │ │ │ │ │ │ └── SearchController.java │ │ │ │ │ ├── service │ │ │ │ │ │ └── SearchService.java │ │ │ │ │ └── configuration │ │ │ │ │ │ └── SearchConfiguration.java │ │ │ │ ├── ws │ │ │ │ │ └── configuration │ │ │ │ │ │ └── WebSocketConfiguration.java │ │ │ │ └── storage │ │ │ │ │ ├── controller │ │ │ │ │ └── StorageController.java │ │ │ │ │ └── service │ │ │ │ │ └── StorageService.java │ │ │ │ ├── BackendApplication.java │ │ │ │ └── controller │ │ │ │ └── BackendController.java │ │ └── resources │ │ │ └── application.properties │ └── test │ │ └── java │ │ └── com │ │ └── linkedin │ │ └── backend │ │ └── BackendApplicationTests.java ├── docker-compose.yml ├── build.gradle.kts └── gradlew.bat ├── frontend ├── src │ ├── vite-env.d.ts │ ├── features │ │ ├── messaging │ │ │ ├── components │ │ │ │ ├── Conversations │ │ │ │ │ ├── Conversations.module.scss │ │ │ │ │ ├── components │ │ │ │ │ │ └── Conversation │ │ │ │ │ │ │ ├── Conversation.module.scss │ │ │ │ │ │ │ └── Conversation.tsx │ │ │ │ │ └── Conversations.tsx │ │ │ │ └── Messages │ │ │ │ │ ├── Messages.module.scss │ │ │ │ │ ├── Messages.tsx │ │ │ │ │ └── components │ │ │ │ │ └── Message │ │ │ │ │ ├── Message.module.scss │ │ │ │ │ └── Message.tsx │ │ │ └── pages │ │ │ │ ├── Messages │ │ │ │ ├── Messaging.module.scss │ │ │ │ └── Messaging.tsx │ │ │ │ └── Conversation │ │ │ │ └── Conversation.module.scss │ │ ├── feed │ │ │ ├── components │ │ │ │ ├── TimeAgo │ │ │ │ │ ├── TimeAgo.module.scss │ │ │ │ │ └── TimeAgo.tsx │ │ │ │ ├── RightSidebar │ │ │ │ │ ├── RightSidebar.module.scss │ │ │ │ │ └── RightSidebar.tsx │ │ │ │ ├── LeftSidebar │ │ │ │ │ ├── LeftSidebar.module.scss │ │ │ │ │ └── LeftSidebar.tsx │ │ │ │ ├── Modal │ │ │ │ │ ├── Modal.module.scss │ │ │ │ │ └── Modal.tsx │ │ │ │ ├── Comment │ │ │ │ │ ├── Comment.module.scss │ │ │ │ │ └── Comment.tsx │ │ │ │ └── Post │ │ │ │ │ └── Post.module.scss │ │ │ ├── pages │ │ │ │ ├── Post │ │ │ │ │ ├── Post.module.scss │ │ │ │ │ └── Post.tsx │ │ │ │ ├── Feed │ │ │ │ │ ├── Feed.module.scss │ │ │ │ │ └── Feed.tsx │ │ │ │ └── Notifications │ │ │ │ │ ├── Notifications.module.scss │ │ │ │ │ └── Notifications.tsx │ │ │ └── utils │ │ │ │ └── date.ts │ │ ├── authentication │ │ │ ├── pages │ │ │ │ ├── VerifyEmail │ │ │ │ │ ├── VerifyEmail.module.scss │ │ │ │ │ └── VerifyEmail.tsx │ │ │ │ ├── ResetPassword │ │ │ │ │ ├── ResetPassword.module.scss │ │ │ │ │ └── ResetPassword.tsx │ │ │ │ ├── Profile │ │ │ │ │ └── Profile.module.scss │ │ │ │ ├── Login │ │ │ │ │ ├── Login.module.scss │ │ │ │ │ └── Login.tsx │ │ │ │ └── Signup │ │ │ │ │ ├── Signup.module.scss │ │ │ │ │ └── Signup.tsx │ │ │ ├── components │ │ │ │ ├── Box │ │ │ │ │ ├── Box.tsx │ │ │ │ │ └── Box.module.scss │ │ │ │ ├── Seperator │ │ │ │ │ ├── Seperator.tsx │ │ │ │ │ └── Seperator.module.scss │ │ │ │ └── AuthenticationLayout │ │ │ │ │ ├── AuthenticationLayout.module.scss │ │ │ │ │ └── AuthenticationLayout.tsx │ │ │ └── hooks │ │ │ │ └── useOauth.ts │ │ ├── networking │ │ │ ├── components │ │ │ │ ├── Title │ │ │ │ │ ├── Title.module.scss │ │ │ │ │ └── Title.tsx │ │ │ │ └── Connection │ │ │ │ │ └── Connection.module.scss │ │ │ └── pages │ │ │ │ ├── Connections │ │ │ │ ├── Connections.module.scss │ │ │ │ └── Connections.tsx │ │ │ │ ├── Invitations │ │ │ │ ├── Invitations.module.scss │ │ │ │ └── Invitations.tsx │ │ │ │ └── Network │ │ │ │ └── Network.module.scss │ │ ├── profile │ │ │ ├── components │ │ │ │ ├── Activity │ │ │ │ │ ├── Activity.module.scss │ │ │ │ │ └── Activity.tsx │ │ │ │ ├── About │ │ │ │ │ ├── About.module.scss │ │ │ │ │ └── About.tsx │ │ │ │ ├── ProfileAndCoverPictureUpdateModal │ │ │ │ │ ├── ProfileAndCoverPictureUpdateModal.module.scss │ │ │ │ │ └── ProfileAndCoverPictureUpdateModal.tsx │ │ │ │ └── Header │ │ │ │ │ └── Header.module.scss │ │ │ └── pages │ │ │ │ ├── Posts │ │ │ │ ├── Posts.module.scss │ │ │ │ └── Posts.tsx │ │ │ │ └── Profile │ │ │ │ ├── Profile.module.scss │ │ │ │ └── Profile.tsx │ │ └── ws │ │ │ └── WebSocketContextProvider.tsx │ ├── hooks │ │ └── usePageTitle.tsx │ ├── components │ │ ├── ApplicationLayout │ │ │ ├── ApplicationLayout.module.scss │ │ │ └── ApplicationLayout.tsx │ │ ├── Input │ │ │ ├── Input.module.scss │ │ │ └── Input.tsx │ │ ├── Button │ │ │ ├── Button.tsx │ │ │ └── Button.module.scss │ │ ├── Loader │ │ │ ├── Loader.tsx │ │ │ └── Loader.module.scss │ │ └── Header │ │ │ ├── components │ │ │ ├── Search │ │ │ │ ├── Search.module.scss │ │ │ │ └── Search.tsx │ │ │ └── Profile │ │ │ │ ├── Profile.module.scss │ │ │ │ └── Profile.tsx │ │ │ └── Header.module.scss │ ├── index.scss │ ├── utils │ │ └── api.ts │ └── main.tsx ├── .stylelintrc.json ├── .gitignore ├── public │ ├── cover.jpeg │ ├── favicon.ico │ ├── avatar.svg │ ├── logo-dark.svg │ └── logo.svg ├── tsconfig.json ├── .env.example ├── vite.config.ts ├── tsconfig.node.json ├── tsconfig.app.json ├── index.html ├── eslint.config.js └── package.json ├── screenshot.png ├── .gitignore ├── .github └── workflows │ ├── frontend.yml │ ├── ci.yml │ └── backend.yml └── readme.md /backend/uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "backend" 2 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yousoumar/linkedin/HEAD/screenshot.png -------------------------------------------------------------------------------- /frontend/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard-scss" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/components/Conversations/Conversations.module.scss: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated folders 2 | node_modules 3 | dist 4 | 5 | # Local env files 6 | .env -------------------------------------------------------------------------------- /frontend/public/cover.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yousoumar/linkedin/HEAD/frontend/public/cover.jpeg -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yousoumar/linkedin/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files 2 | .DS_Store 3 | 4 | # IDE specific files 5 | .vscode 6 | .idea 7 | 8 | event.json -------------------------------------------------------------------------------- /frontend/src/features/feed/components/TimeAgo/TimeAgo.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | color: #666; 3 | font-size: 0.8rem; 4 | } 5 | -------------------------------------------------------------------------------- /backend/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yousoumar/linkedin/HEAD/backend/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/VerifyEmail/VerifyEmail.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | form { 3 | margin-top: 1rem; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/ResetPassword/ResetPassword.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | form { 3 | margin-top: 1rem; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle files 2 | .gradle 3 | build 4 | 5 | # Lucene index files 6 | lucene 7 | 8 | uploads/* 9 | !uploads/.gitkeep 10 | bin -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/dto/Response.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.dto; 2 | 3 | public record Response(String message) { 4 | } 5 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:8080 2 | VITE_GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id 3 | VITE_GOOGLE_OAUTH_URL=https://accounts.google.com/o/oauth2/v2/auth -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/messaging/dto/MessageDto.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.messaging.dto; 2 | 3 | public record MessageDto(Long receiverId, String content) { 4 | } -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/networking/model/Status.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.networking.model; 2 | 3 | public enum Status { 4 | PENDING, 5 | ACCEPTED 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/features/networking/components/Title/Title.module.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | font-weight: bold; 3 | margin-bottom: 0.5rem; 4 | border-bottom: 1px solid #e0e0e0; 5 | padding-bottom: 0.5rem; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/features/networking/pages/Connections/Connections.module.scss: -------------------------------------------------------------------------------- 1 | .connections { 2 | background-color: white; 3 | border-radius: 0.3rem; 4 | border: 1px solid #e0e0e0; 5 | padding: 1rem; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/notifications/model/NotificationType.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.notifications.model; 2 | 3 | public enum NotificationType { 4 | LIKE, 5 | COMMENT, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/hooks/usePageTitle.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export const usePageTitle = (title: string) => { 4 | useEffect(() => { 5 | document.title = "LinkedIn | " + title; 6 | }, [title]); 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/components/Messages/Messages.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | padding: 1rem; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: flex-start; 6 | gap: 1rem; 7 | overflow-y: auto; 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/authentication/dto/AuthenticationResponseBody.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.authentication.dto; 2 | 3 | public record AuthenticationResponseBody(String token, String message) { 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/authentication/dto/AuthenticationOauthRequestBody.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.authentication.dto; 2 | 3 | public record AuthenticationOauthRequestBody(String code, String page) { 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/components/Box/Box.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import classes from "./Box.module.scss"; 3 | export function Box({ children }: { children: ReactNode }) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/features/networking/components/Title/Title.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import classes from "./Title.module.scss"; 3 | export function Title({ children }: { children: ReactNode }) { 4 | return

{children}

; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/components/Seperator/Seperator.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import classes from "./Seperator.module.scss"; 3 | 4 | export function Seperator({ children }: { children?: ReactNode }) { 5 | return
{children}
; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/components/Box/Box.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | @media screen and (width >= 768px) { 3 | padding: 1.5rem; 4 | width: 30rem; 5 | margin: 0 auto; 6 | background-color: white; 7 | border: 1px solid #e0e0e0; 8 | border-radius: 0.5rem; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/components/ApplicationLayout/ApplicationLayout.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | min-height: 100vh; 3 | display: grid; 4 | gap: 1rem; 5 | grid-template-rows: auto 1fr; 6 | 7 | .container { 8 | max-width: 74rem; 9 | width: 100%; 10 | margin: 0 auto; 11 | padding: 1rem; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /backend/src/test/java/com/linkedin/backend/BackendApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class BackendApplicationTests { 8 | 9 | @Test 10 | void contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/feed/repository/CommentRepository.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.feed.repository; 2 | 3 | import com.linkedin.backend.features.feed.model.Comment; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface CommentRepository extends JpaRepository { 7 | } -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/messaging/repository/MessageRepository.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.messaging.repository; 2 | 3 | import com.linkedin.backend.features.messaging.model.Message; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | 7 | public interface MessageRepository extends JpaRepository { 8 | } -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | mysql: 5 | image: mysql:9.1.0 6 | environment: 7 | MYSQL_DATABASE: linkedin 8 | MYSQL_ROOT_PASSWORD: root 9 | ports: 10 | - '3306:3306' 11 | mailhog: 12 | image: mailhog/mailhog:v1.0.1 13 | ports: 14 | - '1025:1025' 15 | - '8025:8025' # Web UI for viewing emails -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/Profile/Profile.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | .inputs { 3 | display: grid; 4 | grid-template-columns: 1fr 1fr; 5 | gap: 1rem; 6 | } 7 | 8 | .buttons { 9 | display: flex; 10 | gap: 1rem; 11 | justify-content: flex-end; 12 | } 13 | 14 | .error { 15 | color: red; 16 | font-size: 0.9rem; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/Login/Login.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | main { 3 | padding: 0 4rem; 4 | } 5 | 6 | form { 7 | margin-top: 1rem; 8 | } 9 | 10 | .disclaimer { 11 | font-size: 0.7rem; 12 | } 13 | 14 | .register { 15 | text-align: center; 16 | } 17 | 18 | .error { 19 | color: red; 20 | margin-bottom: 1rem; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/features/feed/pages/Post/Post.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | gap: 2rem; 4 | 5 | .left, 6 | .right { 7 | display: none; 8 | } 9 | 10 | @media screen and (width >= 1135px) { 11 | grid-template-columns: 14rem 1fr 20rem; 12 | align-items: flex-start; 13 | 14 | .left, 15 | .right { 16 | display: block; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/authentication/dto/AuthenticationRequestBody.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.authentication.dto; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | 5 | public record AuthenticationRequestBody( 6 | @NotBlank(message = "Email is mandatory") String email, 7 | @NotBlank(message = "Password is mandatory") String password 8 | ) { 9 | } -------------------------------------------------------------------------------- /frontend/src/features/authentication/components/Seperator/Seperator.module.scss: -------------------------------------------------------------------------------- 1 | .separator { 2 | display: grid; 3 | grid-template-columns: 1fr auto 1fr; 4 | align-items: center; 5 | gap: 1rem; 6 | margin-block: 1rem; 7 | 8 | &::after, 9 | &::before { 10 | content: ""; 11 | display: block; 12 | height: 1px; 13 | background-color: rgb(0 0 0 / 15%); 14 | margin: 1rem 0; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/features/profile/components/Activity/Activity.module.scss: -------------------------------------------------------------------------------- 1 | .activity { 2 | background-color: white; 3 | border-radius: 0.3rem; 4 | border: 1px solid #e0e0e0; 5 | overflow: hidden; 6 | padding: 1rem; 7 | 8 | .posts { 9 | display: grid; 10 | gap: 1rem; 11 | } 12 | 13 | .more { 14 | border-top: 1px solid #e0e0e0; 15 | text-align: center; 16 | padding-top: 1rem; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/BackendApplication.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class BackendApplication { 8 | public static void main(String[] args) { 9 | SpringApplication.run(BackendApplication.class, args); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/Signup/Signup.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | .logo { 3 | width: 7rem; 4 | } 5 | 6 | main { 7 | padding: 0 4rem; 8 | } 9 | 10 | form { 11 | margin-top: 1rem; 12 | } 13 | 14 | .disclaimer { 15 | font-size: 0.7rem; 16 | } 17 | 18 | .register { 19 | text-align: center; 20 | } 21 | 22 | .error { 23 | color: red; 24 | margin-bottom: 1rem; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/Input/Input.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | gap: 0.5rem; 4 | margin-block: 1rem; 5 | 6 | input { 7 | border: 1px solid #6f6f6f99; 8 | border-radius: 4px; 9 | } 10 | 11 | &.large input { 12 | padding: 1rem; 13 | } 14 | 15 | &.medium input { 16 | padding: 0.5rem; 17 | font-size: 0.9rem; 18 | } 19 | 20 | &.small input { 21 | padding: 0.2rem; 22 | font-size: 0.8rem; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/feed/dto/CommentDto.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.feed.dto; 2 | 3 | public class CommentDto { 4 | private String content; 5 | 6 | public CommentDto(String content) { 7 | this.content = content; 8 | } 9 | 10 | public CommentDto() { 11 | } 12 | 13 | public String getContent() { 14 | return content; 15 | } 16 | 17 | public void setContent(String content) { 18 | this.content = content; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/features/profile/pages/Posts/Posts.module.scss: -------------------------------------------------------------------------------- 1 | .posts { 2 | display: grid; 3 | gap: 1rem; 4 | 5 | h1 { 6 | font-size: 1.5rem; 7 | margin-bottom: 1rem; 8 | font-weight: bold; 9 | } 10 | 11 | .main { 12 | background-color: white; 13 | border-radius: 0.3rem; 14 | border: 1px solid #e0e0e0; 15 | padding: 1rem; 16 | } 17 | 18 | .right { 19 | display: none; 20 | } 21 | 22 | @media screen and (width >= 1024px) { 23 | grid-template-columns: 14rem 1fr 20rem; 24 | 25 | .right { 26 | display: block; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/authentication/repository/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.authentication.repository; 2 | 3 | import java.util.List; 4 | import java.util.Optional; 5 | 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.stereotype.Repository; 8 | 9 | import com.linkedin.backend.features.authentication.model.User; 10 | 11 | @Repository 12 | public interface UserRepository extends JpaRepository { 13 | Optional findByEmail(String email); 14 | 15 | List findAllByIdNot(Long id); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/ApplicationLayout/ApplicationLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | import { WebSocketContextProvider } from "../../features/ws/WebSocketContextProvider"; 3 | import { Header } from "../Header/Header"; 4 | import classes from "./ApplicationLayout.module.scss"; 5 | export function ApplicationLayout() { 6 | return ( 7 | 8 |
9 |
10 |
11 | 12 |
13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/feed/dto/PostDto.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.feed.dto; 2 | 3 | public class PostDto { 4 | private String content; 5 | private String picture = null; 6 | 7 | public PostDto() { 8 | } 9 | 10 | public String getContent() { 11 | return content; 12 | } 13 | 14 | public void setContent(String content) { 15 | this.content = content; 16 | } 17 | 18 | public String getPicture() { 19 | return picture; 20 | } 21 | 22 | public void setPicture(String picture) { 23 | this.picture = picture; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes } from "react"; 2 | import classes from "./Button.module.scss"; 3 | interface IButtonProps extends ButtonHTMLAttributes { 4 | outline?: boolean; 5 | size?: "small" | "medium" | "large"; 6 | } 7 | export function Button({ outline, children, className, size = "large", ...others }: IButtonProps) { 8 | return ( 9 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/notifications/repository/NotificationRepository.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.notifications.repository; 2 | 3 | import com.linkedin.backend.features.authentication.model.User; 4 | import com.linkedin.backend.features.notifications.model.Notification; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | import java.util.List; 8 | 9 | public interface NotificationRepository extends JpaRepository { 10 | List findByRecipient(User recipient); 11 | 12 | List findByRecipientOrderByCreationDateDesc(User user); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/features/networking/components/Connection/Connection.module.scss: -------------------------------------------------------------------------------- 1 | .connection { 2 | display: flex; 3 | align-items: center; 4 | gap: 0.5rem; 5 | font-size: 0.9rem; 6 | 7 | &:not(:last-child) { 8 | border-bottom: 1px solid #e0e0e0; 9 | margin-bottom: 0.5rem; 10 | padding-bottom: 0.5rem; 11 | } 12 | 13 | .avatar { 14 | width: 3rem; 15 | height: 3rem; 16 | border-radius: 50%; 17 | } 18 | 19 | .name { 20 | font-weight: bold; 21 | } 22 | 23 | .actions { 24 | margin-left: auto; 25 | display: flex; 26 | gap: 0.5rem; 27 | } 28 | 29 | .action { 30 | margin: 0; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import classes from "./Loader.module.scss"; 2 | interface ILoaderProps { 3 | isInline?: boolean; 4 | } 5 | export function Loader({ isInline }: ILoaderProps) { 6 | if (isInline) { 7 | return ( 8 |
9 | 10 | 11 | 12 |
13 | ); 14 | } 15 | return ( 16 |
17 | Loading... 18 |
19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/feed/repository/PostRepository.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.feed.repository; 2 | 3 | import com.linkedin.backend.features.feed.model.Post; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.List; 8 | import java.util.Set; 9 | 10 | @Repository 11 | public interface PostRepository extends JpaRepository { 12 | List findByAuthorId(Long authorId); 13 | 14 | List findAllByOrderByCreationDateDesc(); 15 | 16 | List findByAuthorIdInOrderByCreationDateDesc(Set connectedUserIds); 17 | } -------------------------------------------------------------------------------- /frontend/src/features/networking/pages/Invitations/Invitations.module.scss: -------------------------------------------------------------------------------- 1 | .connections { 2 | background-color: white; 3 | border-radius: 0.3rem; 4 | border: 1px solid #e0e0e0; 5 | padding: 1rem; 6 | 7 | .header { 8 | display: flex; 9 | align-items: center; 10 | gap: 0.5rem; 11 | border-bottom: 1px solid #e0e0e0; 12 | padding-bottom: 0.5rem; 13 | margin-bottom: 0.5rem; 14 | 15 | button { 16 | background-color: #f5f5f5; 17 | border-radius: 999px; 18 | padding: 0.3rem 0.5rem; 19 | font-size: 0.8rem; 20 | 21 | &.active { 22 | background-color: var(--primary-color); 23 | color: white; 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Linkedin 8 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #0a66c2; 3 | --primary-color-dark: #004182; 4 | } 5 | 6 | * { 7 | margin: 0; 8 | padding: 0; 9 | box-sizing: border-box; 10 | font: inherit; 11 | color: inherit; 12 | } 13 | 14 | html { 15 | font-family: Poppins, sans-serif; 16 | background-color: #f4f2ee; 17 | overflow-x: hidden; 18 | } 19 | 20 | img { 21 | max-width: 100%; 22 | display: block; 23 | object-fit: cover; 24 | } 25 | 26 | button { 27 | outline: none; 28 | border: none; 29 | background-color: transparent; 30 | cursor: pointer; 31 | text-align: left; 32 | } 33 | 34 | a { 35 | text-decoration: none; 36 | } 37 | 38 | ul { 39 | list-style: none; 40 | } 41 | 42 | strong { 43 | font-weight: 600; 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/messaging/repository/ConversationRepository.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.messaging.repository; 2 | 3 | import com.linkedin.backend.features.authentication.model.User; 4 | import com.linkedin.backend.features.messaging.model.Conversation; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.util.List; 9 | import java.util.Optional; 10 | 11 | @Repository 12 | public interface ConversationRepository extends JpaRepository { 13 | Optional findByAuthorAndRecipient(User author, User recipient); 14 | 15 | List findByAuthorOrRecipient(User userOne, User userTwo); 16 | } -------------------------------------------------------------------------------- /frontend/src/components/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes } from "react"; 2 | import classes from "./Input.module.scss"; 3 | 4 | interface IInputProps extends Omit, "size"> { 5 | label?: string; 6 | size?: "small" | "medium" | "large"; 7 | } 8 | 9 | export function Input({ label, size, width, ...others }: IInputProps) { 10 | return ( 11 |
12 | {label ? ( 13 | 16 | ) : null} 17 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/features/profile/pages/Profile/Profile.module.scss: -------------------------------------------------------------------------------- 1 | .profile { 2 | display: grid; 3 | gap: 1rem; 4 | 5 | .main { 6 | display: grid; 7 | gap: 1rem; 8 | } 9 | 10 | .experience, 11 | .education, 12 | .skills { 13 | background-color: white; 14 | border-radius: 0.3rem; 15 | border: 1px solid #e0e0e0; 16 | overflow: hidden; 17 | padding: 1rem; 18 | } 19 | 20 | h2 { 21 | font-weight: bold; 22 | font-size: 1.1rem; 23 | margin-bottom: 0.5rem; 24 | } 25 | 26 | .sidebar { 27 | display: none; 28 | } 29 | 30 | @media screen and (width >= 1024px) { 31 | grid-template-columns: 1fr 20rem; 32 | align-items: flex-start; 33 | 34 | .sidebar { 35 | display: block; 36 | height: max-content; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/frontend.yml: -------------------------------------------------------------------------------- 1 | name: Frontend 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | frontend: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Setup Node.js 22 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 22 18 | 19 | - name: Install dependencies 20 | working-directory: ./frontend 21 | run: npm ci 22 | 23 | - name: Run ESLint 24 | working-directory: ./frontend 25 | run: npm run lint 26 | 27 | - name: Run Stylelint 28 | working-directory: ./frontend 29 | run: npm run lint:css 30 | 31 | - name: Build frontend 32 | working-directory: ./frontend 33 | run: npm run build 34 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/components/Messages/Messages.tsx: -------------------------------------------------------------------------------- 1 | import { IUser } from "../../../authentication/contexts/AuthenticationContextProvider"; 2 | import classes from "./Messages.module.scss"; 3 | import { Message } from "./components/Message/Message"; 4 | 5 | export interface IMessage { 6 | id: number; 7 | sender: IUser; 8 | receiver: IUser; 9 | content: string; 10 | isRead: boolean; 11 | createdAt: string; 12 | } 13 | 14 | interface IMessagesProps { 15 | messages: IMessage[]; 16 | user: IUser | null; 17 | } 18 | 19 | export function Messages({ messages, user }: IMessagesProps) { 20 | return ( 21 |
22 | {messages.map((message) => ( 23 | 24 | ))} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/features/profile/components/About/About.module.scss: -------------------------------------------------------------------------------- 1 | .about { 2 | background-color: white; 3 | border-radius: 0.3rem; 4 | border: 1px solid #e0e0e0; 5 | overflow: hidden; 6 | padding: 1rem; 7 | 8 | .header { 9 | display: flex; 10 | align-items: center; 11 | justify-content: space-between; 12 | 13 | .actions { 14 | display: flex; 15 | gap: 0.5rem; 16 | } 17 | 18 | button { 19 | background-color: #f5f5f5; 20 | width: 2rem; 21 | height: 2rem; 22 | border-radius: 50%; 23 | display: grid; 24 | place-items: center; 25 | transition: background-color 0.3s; 26 | 27 | &:hover { 28 | background-color: #e0e0e0; 29 | } 30 | } 31 | 32 | svg { 33 | width: 1rem; 34 | } 35 | } 36 | 37 | input { 38 | width: 100%; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=backend 2 | spring.datasource.url=jdbc:mysql://localhost:3306/linkedin?createDatabaseIfNotExist=true 3 | spring.datasource.username=root 4 | spring.datasource.password=root 5 | spring.jpa.hibernate.ddl-auto=create 6 | spring.jpa.show-sql=false 7 | spring.jpa.properties.hibernate.format_sql=true 8 | jwt.secret.key=8dc2d2829a1bc4e11a57ca66da09c0cdb547c4e03d6108c629160e50d536bfe2 9 | spring.mail.host=localhost 10 | spring.mail.port=1025 11 | spring.mail.properties.mail.smtp.auth=false 12 | spring.mail.properties.mail.smtp.starttls.enable=false 13 | oauth.google.client.id=${OAUTH_GOOGLE_CLIENT_ID:} 14 | oauth.google.client.secret=${OAUTH_GOOGLE_CLIENT_SECRET:} 15 | spring.jpa.properties.hibernate.search.backend.type=lucene 16 | spring.jpa.properties.hibernate.search.backend.directory.root=./lucene/indexes -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /frontend/src/features/feed/components/RightSidebar/RightSidebar.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | background-color: white; 3 | border-radius: 0.3rem; 4 | border: 1px solid #e0e0e0; 5 | padding: 1rem; 6 | 7 | h3 { 8 | font-weight: bold; 9 | margin-bottom: 0.5rem; 10 | } 11 | 12 | .items { 13 | display: grid; 14 | gap: 1rem; 15 | } 16 | 17 | .item { 18 | display: flex; 19 | gap: 1rem; 20 | font-size: 0.8rem; 21 | 22 | button { 23 | flex-shrink: 0; 24 | } 25 | 26 | .avatar { 27 | width: 3rem; 28 | height: 3rem; 29 | border-radius: 50%; 30 | overflow: hidden; 31 | } 32 | 33 | .name { 34 | font-weight: bold; 35 | } 36 | 37 | button { 38 | text-align: left; 39 | } 40 | 41 | .button { 42 | width: auto; 43 | margin: 0.3rem 0 0; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/features/feed/components/TimeAgo/TimeAgo.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, useEffect, useState } from "react"; 2 | import { timeAgo } from "../../utils/date"; 3 | import classes from "./TimeAgo.module.scss"; 4 | 5 | interface ITimeAgoProps extends HTMLAttributes { 6 | date: string; 7 | edited?: boolean; 8 | } 9 | 10 | export function TimeAgo({ date, edited, className, ...others }: ITimeAgoProps) { 11 | const [time, setTime] = useState(timeAgo(new Date(date))); 12 | 13 | useEffect(() => { 14 | const interval = setInterval(() => { 15 | setTime(timeAgo(new Date(date))); 16 | }, 1000); 17 | 18 | return () => clearInterval(interval); 19 | }, [date]); 20 | 21 | return ( 22 |
23 | {time} 24 | {edited ? . Edited : null} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/features/feed/pages/Feed/Feed.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | height: 100%; 3 | display: grid; 4 | gap: 2rem; 5 | 6 | .left, 7 | .right { 8 | display: none; 9 | } 10 | 11 | .center { 12 | display: grid; 13 | gap: 1rem; 14 | height: 100%; 15 | grid-template-rows: auto 1fr; 16 | } 17 | 18 | .posting { 19 | background-color: white; 20 | border-radius: 0.3rem; 21 | border: 1px solid #e0e0e0; 22 | padding: 1rem; 23 | display: grid; 24 | grid-template-columns: 5rem 1fr; 25 | gap: 1rem; 26 | 27 | .avatar { 28 | width: 5rem; 29 | height: 5rem; 30 | border-radius: 50%; 31 | } 32 | } 33 | 34 | .error { 35 | color: red; 36 | } 37 | 38 | @media screen and (width >= 1135px) { 39 | grid-template-columns: 14rem 1fr 20rem; 40 | align-items: flex-start; 41 | 42 | .left, 43 | .right { 44 | display: block; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/authentication/utils/Encoder.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.authentication.utils; 2 | 3 | 4 | import org.springframework.stereotype.Component; 5 | 6 | import java.security.MessageDigest; 7 | import java.security.NoSuchAlgorithmException; 8 | import java.util.Base64; 9 | 10 | 11 | @Component 12 | public class Encoder { 13 | 14 | public String encode(CharSequence rawPassword) { 15 | try { 16 | MessageDigest digest = MessageDigest.getInstance("SHA-256"); 17 | byte[] hash = digest.digest(rawPassword.toString().getBytes()); 18 | return Base64.getEncoder().encodeToString(hash); 19 | } catch (NoSuchAlgorithmException e) { 20 | throw new RuntimeException("Error encoding password", e); 21 | } 22 | } 23 | 24 | public boolean matches(CharSequence rawPassword, String encodedPassword) { 25 | return encode(rawPassword).equals(encodedPassword); 26 | } 27 | } -------------------------------------------------------------------------------- /frontend/src/features/feed/pages/Notifications/Notifications.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | gap: 2rem; 4 | 5 | .left, 6 | .right { 7 | display: none; 8 | } 9 | 10 | .center { 11 | background-color: white; 12 | border-radius: 0.3rem; 13 | border: 1px solid #e0e0e0; 14 | } 15 | 16 | .notification { 17 | display: flex; 18 | align-items: center; 19 | gap: 0.5rem; 20 | padding: 1rem; 21 | width: 100%; 22 | 23 | &:not(:last-child) { 24 | padding-bottom: 1rem; 25 | border-bottom: 1px solid #e0e0e0; 26 | } 27 | 28 | &.unread { 29 | background-color: #f0f0f0; 30 | } 31 | 32 | .avatar { 33 | width: 40px; 34 | height: 40px; 35 | border-radius: 50%; 36 | } 37 | } 38 | 39 | @media screen and (width >= 1135px) { 40 | grid-template-columns: 14rem 1fr 20rem; 41 | align-items: flex-start; 42 | 43 | .left, 44 | .right { 45 | display: block; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/search/controller/SearchController.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.search.controller; 2 | 3 | import com.linkedin.backend.features.authentication.model.User; 4 | import com.linkedin.backend.features.search.service.SearchService; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.RequestMapping; 7 | import org.springframework.web.bind.annotation.RequestParam; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | import java.util.List; 11 | 12 | @RestController 13 | @RequestMapping("/api/v1/search") 14 | public class SearchController { 15 | private final SearchService searchService; 16 | 17 | public SearchController(SearchService searchService) { 18 | this.searchService = searchService; 19 | } 20 | 21 | @GetMapping("/users") 22 | public List searchUsers(@RequestParam String query) { 23 | return searchService.searchUsers(query); 24 | } 25 | } -------------------------------------------------------------------------------- /frontend/src/features/feed/utils/date.ts: -------------------------------------------------------------------------------- 1 | export function timeAgo(date: Date): string { 2 | const now = new Date(); 3 | const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); 4 | 5 | let interval = Math.floor(seconds / 31536000); 6 | if (interval >= 1) { 7 | return interval === 1 ? "1 year ago" : `${interval} years ago`; 8 | } 9 | 10 | interval = Math.floor(seconds / 2592000); 11 | if (interval >= 1) { 12 | return interval === 1 ? "1 month ago" : `${interval} months ago`; 13 | } 14 | 15 | interval = Math.floor(seconds / 86400); 16 | if (interval >= 1) { 17 | return interval === 1 ? "1 day ago" : `${interval} days ago`; 18 | } 19 | 20 | interval = Math.floor(seconds / 3600); 21 | if (interval >= 1) { 22 | return interval === 1 ? "1 hour ago" : `${interval} hours ago`; 23 | } 24 | 25 | interval = Math.floor(seconds / 60); 26 | if (interval >= 1) { 27 | return interval === 1 ? "1 minute ago" : `${interval} minutes ago`; 28 | } 29 | 30 | return "Just now"; 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | detect-changes: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | frontend-changed: ${{ steps.changes.outputs.frontend }} 14 | backend-changed: ${{ steps.changes.outputs.backend }} 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Detect changes 20 | uses: dorny/paths-filter@v3 21 | id: changes 22 | with: 23 | filters: | 24 | frontend: 25 | - 'frontend/**' 26 | backend: 27 | - 'backend/**' 28 | 29 | frontend: 30 | needs: detect-changes 31 | if: needs.detect-changes.outputs.frontend-changed == 'true' 32 | uses: ./.github/workflows/frontend.yml 33 | 34 | backend: 35 | needs: detect-changes 36 | if: needs.detect-changes.outputs.backend-changed == 'true' 37 | uses: ./.github/workflows/backend.yml 38 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "lint:css": "stylelint '**/*.scss'" 12 | }, 13 | "dependencies": { 14 | "@stomp/stompjs": "^7.0.0", 15 | "react": "^18.3.1", 16 | "react-dom": "^18.3.1", 17 | "react-router-dom": "^6.27.0" 18 | }, 19 | "devDependencies": { 20 | "@eslint/js": "^9.11.1", 21 | "@types/react": "^18.3.10", 22 | "@types/react-dom": "^18.3.0", 23 | "@vitejs/plugin-react": "^4.3.2", 24 | "eslint": "^9.11.1", 25 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 26 | "eslint-plugin-react-refresh": "^0.4.12", 27 | "globals": "^15.9.0", 28 | "sass": "^1.80.3", 29 | "stylelint": "^16.10.0", 30 | "stylelint-config-standard-scss": "^13.1.0", 31 | "typescript": "^5.5.3", 32 | "typescript-eslint": "^8.7.0", 33 | "vite": "^5.4.8" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/public/avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/authentication/configuration/AuthenticationConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.authentication.configuration; 2 | 3 | import com.linkedin.backend.features.authentication.filter.AuthenticationFilter; 4 | import org.springframework.boot.web.servlet.FilterRegistrationBean; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.web.client.RestTemplate; 8 | 9 | 10 | @Configuration 11 | public class AuthenticationConfiguration { 12 | @Bean 13 | public FilterRegistrationBean customAuthenticationFilter(AuthenticationFilter filter) { 14 | FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); 15 | registrationBean.setFilter(filter); 16 | registrationBean.addUrlPatterns("/api/*"); 17 | return registrationBean; 18 | } 19 | 20 | @Bean 21 | public RestTemplate restTemplate() { 22 | return new RestTemplate(); 23 | } 24 | } -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/ws/configuration/WebSocketConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.ws.configuration; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.messaging.simp.config.MessageBrokerRegistry; 5 | import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; 6 | import org.springframework.web.socket.config.annotation.StompEndpointRegistry; 7 | import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; 8 | 9 | @Configuration 10 | @EnableWebSocketMessageBroker 11 | public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { 12 | 13 | @Override 14 | public void registerStompEndpoints(StompEndpointRegistry registry) { 15 | registry.addEndpoint("/ws").setAllowedOriginPatterns("*"); 16 | } 17 | 18 | @Override 19 | public void configureMessageBroker(MessageBrokerRegistry registry) { 20 | registry.enableSimpleBroker("/topic"); 21 | registry.setApplicationDestinationPrefixes("/app"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/components/Header/components/Search/Search.module.scss: -------------------------------------------------------------------------------- 1 | .search { 2 | position: relative; 3 | 4 | .suggestions { 5 | position: absolute; 6 | top: 100%; 7 | left: 0; 8 | right: 0; 9 | background-color: white; 10 | border: 1px solid #e0e0e0; 11 | border-radius: 0 0 0.3rem 0.3rem; 12 | z-index: 999; 13 | max-height: 16rem; 14 | overflow-y: auto; 15 | box-shadow: 0 2px 4px rgb(0 0 0 / 10%); 16 | } 17 | 18 | .suggestion { 19 | button { 20 | display: flex; 21 | align-items: center; 22 | gap: 0.5rem; 23 | padding: 0.5rem; 24 | width: 100%; 25 | 26 | &:hover { 27 | background-color: #f9f9f9; 28 | } 29 | } 30 | 31 | &:not(:last-child) { 32 | border-bottom: 1px solid #e0e0e0; 33 | } 34 | 35 | .avatar { 36 | width: 3rem; 37 | height: 3rem; 38 | border-radius: 50%; 39 | } 40 | 41 | .top { 42 | font-size: 0.8rem; 43 | grid-template-columns: 3rem 1fr auto; 44 | padding-inline: 0; 45 | } 46 | 47 | .name { 48 | font-weight: bold; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/search/service/SearchService.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.search.service; 2 | 3 | import com.linkedin.backend.features.authentication.model.User; 4 | import jakarta.persistence.EntityManager; 5 | import org.hibernate.search.mapper.orm.Search; 6 | import org.hibernate.search.mapper.orm.session.SearchSession; 7 | import org.springframework.stereotype.Service; 8 | 9 | import java.util.List; 10 | 11 | @Service 12 | public class SearchService { 13 | private final EntityManager entityManager; 14 | 15 | public SearchService(EntityManager entityManager) { 16 | this.entityManager = entityManager; 17 | } 18 | 19 | public List searchUsers(String query) { 20 | SearchSession searchSession = Search.session(entityManager); 21 | 22 | return searchSession.search(User.class) 23 | .where(f -> f.match() 24 | .fields("firstName", "lastName", "position", "company") 25 | .matching(query) 26 | .fuzzy(2) 27 | ) 28 | .fetchAllHits(); 29 | } 30 | } -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | gap: 0.5rem; 6 | width: 100%; 7 | background-color: var(--primary-color); 8 | color: white; 9 | border-radius: 9999px; 10 | margin-block: 1rem; 11 | font-weight: bold; 12 | transition: 0.3s; 13 | 14 | svg { 15 | width: 1.5rem; 16 | height: 1.5rem; 17 | } 18 | 19 | &.large { 20 | padding: 1rem; 21 | } 22 | 23 | &.medium { 24 | padding: 0.5rem; 25 | font-size: 0.9rem; 26 | } 27 | 28 | &.small { 29 | padding: 0.4rem; 30 | font-size: 0.8rem; 31 | } 32 | 33 | &:disabled { 34 | color: black; 35 | background-color: rgb(217 217 217); 36 | cursor: not-allowed; 37 | } 38 | 39 | &:not(:disabled, .outline):hover { 40 | background-color: var(--primary-color-dark); 41 | } 42 | 43 | &.outline { 44 | background-color: white; 45 | border: 1px solid rgb(0 0 0 / 60%); 46 | color: black; 47 | font-weight: normal; 48 | 49 | &:not(:disabled):hover { 50 | background-color: rgb(0 0 0 / 10%); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/features/ws/WebSocketContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { CompatClient, Stomp } from "@stomp/stompjs"; 2 | import { createContext, ReactNode, useContext, useEffect, useState } from "react"; 3 | 4 | const WsContext = createContext(null); 5 | 6 | export const useWebSocket = () => useContext(WsContext); 7 | 8 | export const WebSocketContextProvider = ({ children }: { children: ReactNode }) => { 9 | const [stompClient, setStompClient] = useState(null); 10 | 11 | useEffect(() => { 12 | const client = Stomp.client(`${import.meta.env.VITE_API_URL}/ws`); 13 | 14 | client.connect( 15 | {}, 16 | () => { 17 | console.log("Connected to WebSocket"); 18 | setStompClient(client); 19 | }, 20 | (error: unknown) => { 21 | console.error("Error connecting to WebSocket:", error); 22 | } 23 | ); 24 | 25 | return () => { 26 | if (client.connected) { 27 | client.disconnect(() => console.log("Disconnected from WebSocket")); 28 | } 29 | }; 30 | }, []); 31 | 32 | return {children}; 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/src/components/Header/components/Profile/Profile.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | .toggle { 3 | display: flex; 4 | align-items: center; 5 | flex-direction: column; 6 | border-right: 1px solid #e0e0e0; 7 | padding-right: 1rem; 8 | 9 | .avatar { 10 | width: 1.5rem; 11 | height: 1.5rem; 12 | border-radius: 50%; 13 | } 14 | } 15 | 16 | .menu { 17 | background-color: white; 18 | border: 1px solid #e0e0e0; 19 | padding: 1rem; 20 | border-radius: 0.3rem 0 0.3rem 0.3rem; 21 | position: absolute; 22 | top: 5rem; 23 | right: 1rem; 24 | width: min(18rem, 100%); 25 | 26 | .name { 27 | font-weight: bold; 28 | text-wrap: nowrap; 29 | } 30 | 31 | .content { 32 | display: grid; 33 | gap: 0.5rem; 34 | grid-template-columns: 3rem 1fr; 35 | align-items: center; 36 | } 37 | 38 | .avatar { 39 | width: 3rem; 40 | height: 3rem; 41 | border-radius: 50%; 42 | overflow: hidden; 43 | } 44 | 45 | .links { 46 | display: grid; 47 | gap: 0.5rem; 48 | } 49 | 50 | .button { 51 | margin-bottom: 0; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/components/Conversations/components/Conversation/Conversation.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | gap: 1rem; 4 | align-items: center; 5 | text-align: left; 6 | padding: 0.7rem; 7 | width: 100%; 8 | font-size: 0.9rem; 9 | border-bottom: 1px solid #e0e0e0; 10 | padding-bottom: 1rem; 11 | position: relative; 12 | 13 | .unread { 14 | position: absolute; 15 | right: 1rem; 16 | top: 50%; 17 | transform: translateY(-50%); 18 | background-color: crimson; 19 | color: white; 20 | display: grid; 21 | place-items: center; 22 | width: 1.5rem; 23 | height: 1.5rem; 24 | border-radius: 50%; 25 | font-size: 0.7rem; 26 | } 27 | 28 | &.selected { 29 | background-color: #f5f5f5; 30 | } 31 | 32 | .avatar { 33 | width: 3rem; 34 | height: 3rem; 35 | flex-shrink: 0; 36 | border-radius: 50%; 37 | } 38 | 39 | .name { 40 | font-weight: bold; 41 | } 42 | 43 | .content { 44 | display: -webkit-box; 45 | -webkit-line-clamp: 1; 46 | line-clamp: 1; 47 | -webkit-box-orient: vertical; 48 | overflow: hidden; 49 | text-overflow: ellipsis; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/authentication/utils/EmailService.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.authentication.utils; 2 | 3 | import jakarta.mail.MessagingException; 4 | import jakarta.mail.internet.MimeMessage; 5 | import org.springframework.mail.javamail.JavaMailSender; 6 | import org.springframework.mail.javamail.MimeMessageHelper; 7 | import org.springframework.stereotype.Service; 8 | 9 | import java.io.UnsupportedEncodingException; 10 | 11 | @Service 12 | public class EmailService { 13 | private final JavaMailSender mailSender; 14 | 15 | public EmailService(JavaMailSender mailSender) { 16 | this.mailSender = mailSender; 17 | } 18 | 19 | public void sendEmail(String email, String subject, String content) throws MessagingException, UnsupportedEncodingException { 20 | MimeMessage message = mailSender.createMimeMessage(); 21 | MimeMessageHelper helper = new MimeMessageHelper(message); 22 | 23 | helper.setFrom("no-reply@linkedin.com", "LinkedIn"); 24 | helper.setTo(email); 25 | 26 | helper.setSubject(subject); 27 | helper.setText(content, true); 28 | 29 | mailSender.send(message); 30 | } 31 | } -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/networking/repository/ConnectionRepository.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.networking.repository; 2 | 3 | import com.linkedin.backend.features.authentication.model.User; 4 | import com.linkedin.backend.features.networking.model.Connection; 5 | import com.linkedin.backend.features.networking.model.Status; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.data.repository.query.Param; 9 | 10 | import java.util.List; 11 | 12 | public interface ConnectionRepository extends JpaRepository { 13 | boolean existsByAuthorAndRecipient(User sender, User recipient); 14 | 15 | List findAllByAuthorOrRecipient(User userOne, User userTwo); 16 | 17 | @Query("SELECT c FROM connections c WHERE (c.author = :user OR c.recipient = :user) AND c.status = :status") 18 | List findConnectionsByUserAndStatus(@Param("user") User user, @Param("status") Status status); 19 | 20 | List findByAuthorIdAndStatusOrRecipientIdAndStatus(Long authenticatedUserId, Status status, Long authenticatedUserId1, Status status1); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/components/Messages/components/Message/Message.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | width: 70%; 3 | 4 | .message { 5 | border-radius: 0 1rem 1rem; 6 | padding: 1rem; 7 | background-color: #f5f5f5; 8 | 9 | .top { 10 | display: flex; 11 | align-items: center; 12 | gap: 1rem; 13 | font-size: 0.8rem; 14 | border-bottom: 1px solid #e0e0e0; 15 | padding-bottom: 0.5rem; 16 | margin-bottom: 0.5rem; 17 | } 18 | 19 | .time { 20 | color: inherit; 21 | } 22 | 23 | .avatar { 24 | width: 2rem; 25 | height: 2rem; 26 | border-radius: 50%; 27 | } 28 | 29 | .name { 30 | font-weight: bold; 31 | } 32 | } 33 | 34 | &.sent { 35 | margin-left: auto; 36 | 37 | .message { 38 | background-color: var(--primary-color); 39 | color: white; 40 | border-radius: 1rem 0 1rem 1rem; 41 | 42 | .top { 43 | border-color: inherit; 44 | } 45 | } 46 | } 47 | 48 | .status { 49 | display: flex; 50 | gap: 0.5rem; 51 | font-size: 0.7rem; 52 | color: #9e9e9e; 53 | align-items: center; 54 | margin-top: 0.2rem; 55 | 56 | svg { 57 | width: 1em; 58 | height: 1em; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/pages/Messages/Messaging.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | gap: 2rem; 4 | height: 100%; 5 | 6 | .header { 7 | display: flex; 8 | justify-content: space-between; 9 | align-items: center; 10 | gap: 1rem; 11 | padding: 1rem; 12 | border-bottom: 1px solid #e0e0e0; 13 | 14 | button.new { 15 | background-color: #f5f5f5; 16 | width: 2rem; 17 | height: 2rem; 18 | border-radius: 50%; 19 | transition: background-color 0.3s; 20 | display: grid; 21 | place-items: center; 22 | 23 | &:hover { 24 | background-color: #e0e0e0; 25 | } 26 | } 27 | } 28 | 29 | .messaging { 30 | height: 100%; 31 | background-color: white; 32 | border-radius: 0.3rem; 33 | border: 1px solid #e0e0e0; 34 | } 35 | 36 | .adds { 37 | display: none; 38 | } 39 | 40 | @media screen and (width >= 1024px) { 41 | .messaging { 42 | display: grid; 43 | grid-template-columns: 16rem 1fr; 44 | 45 | .sidebar { 46 | border-right: 1px solid #e0e0e0; 47 | } 48 | } 49 | } 50 | 51 | @media screen and (width >= 1135px) { 52 | grid-template-columns: 1fr 20rem; 53 | align-items: flex-start; 54 | 55 | .adds { 56 | display: block; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/backend.yml: -------------------------------------------------------------------------------- 1 | name: Backend 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | backend: 8 | runs-on: ubuntu-latest 9 | 10 | services: 11 | mysql: 12 | image: mysql:8.0 13 | env: 14 | MYSQL_ROOT_PASSWORD: root 15 | MYSQL_DATABASE: linkedin 16 | ports: 17 | - 3306:3306 18 | options: >- 19 | --health-cmd="mysqladmin ping" 20 | --health-interval=10s 21 | --health-timeout=5s 22 | --health-retries=3 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup JDK 21 29 | uses: actions/setup-java@v4 30 | with: 31 | java-version: "21" 32 | distribution: "temurin" 33 | 34 | - name: Grant execute permission for gradlew 35 | working-directory: ./backend 36 | run: chmod +x gradlew 37 | 38 | - name: Run tests 39 | working-directory: ./backend 40 | run: ./gradlew test 41 | env: 42 | SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/linkedin 43 | SPRING_DATASOURCE_USERNAME: root 44 | SPRING_DATASOURCE_PASSWORD: root 45 | 46 | - name: Build application 47 | working-directory: ./backend 48 | run: ./gradlew build -x test 49 | -------------------------------------------------------------------------------- /frontend/src/features/profile/components/ProfileAndCoverPictureUpdateModal/ProfileAndCoverPictureUpdateModal.module.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | background-color: rgb(0 0 0 / 50%); 8 | z-index: 99; 9 | display: flex; 10 | flex-direction: column; 11 | padding: 1rem; 12 | 13 | img { 14 | height: 12rem; 15 | width: 100%; 16 | } 17 | 18 | .content { 19 | background-color: white; 20 | border-radius: 0.3rem; 21 | padding: 0.5rem; 22 | margin-top: 5.5rem; 23 | max-width: 45rem; 24 | width: 100%; 25 | margin-inline: auto; 26 | } 27 | 28 | header { 29 | display: flex; 30 | gap: 3rem; 31 | justify-content: space-between; 32 | align-items: center; 33 | border-bottom: 1px solid #e0e0e0; 34 | padding-bottom: 0.5rem; 35 | } 36 | 37 | .avatar { 38 | width: 7rem; 39 | height: 7rem; 40 | border-radius: 50%; 41 | margin-block: 1rem; 42 | border: 4px solid black; 43 | overflow: hidden; 44 | margin-inline: auto; 45 | 46 | img { 47 | width: 100%; 48 | height: 100%; 49 | } 50 | } 51 | 52 | .actions { 53 | display: flex; 54 | gap: 1rem; 55 | justify-content: flex-end; 56 | border-top: 1px solid #e0e0e0; 57 | padding-top: 0.5rem; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/features/feed/pages/Post/Post.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { request } from "../../../../utils/api"; 4 | import { useAuthentication } from "../../../authentication/contexts/AuthenticationContextProvider"; 5 | import { LeftSidebar } from "../../components/LeftSidebar/LeftSidebar"; 6 | import { IPost, Post } from "../../components/Post/Post"; 7 | import { RightSidebar } from "../../components/RightSidebar/RightSidebar"; 8 | import classes from "./Post.module.scss"; 9 | export function PostPage() { 10 | const [posts, setPosts] = useState([]); 11 | const { id } = useParams(); 12 | const { user } = useAuthentication(); 13 | 14 | useEffect(() => { 15 | request({ 16 | endpoint: `/api/v1/feed/posts/${id}`, 17 | onSuccess: (post) => setPosts([post]), 18 | onFailure: (error) => console.log(error), 19 | }); 20 | }, [id]); 21 | 22 | return ( 23 |
24 |
25 | 26 |
27 |
28 | {posts.length > 0 && } 29 |
30 |
31 | 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/features/feed/components/LeftSidebar/LeftSidebar.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | text-align: center; 3 | padding: 1rem; 4 | background-color: white; 5 | border-radius: 0.3rem; 6 | border: 1px solid #e0e0e0; 7 | 8 | .cover { 9 | height: 7rem; 10 | margin: -1rem; 11 | 12 | img { 13 | width: 100%; 14 | height: 100%; 15 | object-fit: cover; 16 | } 17 | } 18 | 19 | .avatar { 20 | margin-top: -3rem; 21 | width: 6rem; 22 | height: 6rem; 23 | margin-inline: auto; 24 | border-radius: 50%; 25 | overflow: hidden; 26 | border: 2px solid black; 27 | position: relative; 28 | z-index: 2; 29 | 30 | img { 31 | width: 100%; 32 | height: 100%; 33 | object-fit: cover; 34 | } 35 | } 36 | 37 | .name { 38 | font-weight: bold; 39 | } 40 | 41 | .title { 42 | border-bottom: 1px solid #e0e0e0; 43 | padding-bottom: 0.5rem; 44 | font-size: 0.9rem; 45 | } 46 | 47 | .info { 48 | text-align: left; 49 | font-size: 0.8rem; 50 | margin-top: 1rem; 51 | color: rgb(0 0 0 / 60%); 52 | 53 | .item { 54 | display: flex; 55 | justify-content: space-between; 56 | align-items: center; 57 | margin-top: 0.3rem; 58 | width: 100%; 59 | 60 | .value { 61 | font-weight: bold; 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/components/AuthenticationLayout/AuthenticationLayout.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | grid-template-rows: auto 1fr auto; 4 | min-height: 100vh; 5 | 6 | .container { 7 | max-width: 74rem; 8 | width: 100%; 9 | margin: 0 auto; 10 | padding: 2rem; 11 | } 12 | 13 | a { 14 | color: var(--primary-color); 15 | font-weight: bold; 16 | } 17 | 18 | header { 19 | background: white; 20 | border: 1px solid #e0e0e0; 21 | 22 | .container { 23 | padding-block: 1rem; 24 | } 25 | 26 | a { 27 | display: block; 28 | width: max-content; 29 | } 30 | } 31 | 32 | .logo { 33 | width: 7rem; 34 | } 35 | 36 | main { 37 | display: grid; 38 | align-items: center; 39 | } 40 | 41 | h1 { 42 | font-size: 2rem; 43 | font-weight: bold; 44 | margin-bottom: 1rem; 45 | } 46 | 47 | footer { 48 | font-size: 0.8rem; 49 | background: white; 50 | border: 1px solid #e0e0e0; 51 | 52 | a { 53 | color: rgb(0 0 0 / 60%); 54 | font-weight: normal; 55 | } 56 | 57 | img { 58 | width: 4rem; 59 | height: auto; 60 | } 61 | 62 | ul { 63 | display: flex; 64 | align-items: center; 65 | gap: 1rem; 66 | margin-top: 2rem; 67 | flex-wrap: wrap; 68 | padding: 0; 69 | } 70 | 71 | li { 72 | display: flex; 73 | gap: 0.5rem; 74 | align-items: center; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /frontend/src/components/Loader/Loader.module.scss: -------------------------------------------------------------------------------- 1 | .global { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | 6 | img { 7 | width: 10rem; 8 | margin: 0 auto; 9 | height: auto; 10 | margin-top: 12rem; 11 | } 12 | 13 | .container, 14 | .content { 15 | height: 0.1rem; 16 | width: 10rem; 17 | border-radius: 9999px; 18 | } 19 | 20 | .container { 21 | margin-top: 1rem; 22 | background-color: rgb(255 255 255); 23 | } 24 | 25 | .content { 26 | width: 4rem; 27 | background-color: var(--primary-color); 28 | animation: slide 1s infinite alternate; 29 | } 30 | 31 | @keyframes slide { 32 | 0% { 33 | transform: translateX(0); 34 | } 35 | 36 | 100% { 37 | transform: translateX(6rem); 38 | } 39 | } 40 | } 41 | 42 | .inline { 43 | display: flex; 44 | justify-content: center; 45 | gap: 0.5rem; 46 | padding-top: 1rem; 47 | 48 | span { 49 | display: inline-block; 50 | width: 0.5rem; 51 | height: 0.5rem; 52 | border-radius: 50%; 53 | background-color: var(--primary-color); 54 | animation: bounce 0.4s infinite alternate; 55 | } 56 | 57 | span:nth-child(2) { 58 | animation-delay: 0.1s; 59 | } 60 | 61 | span:nth-child(3) { 62 | animation-delay: 0.2s; 63 | } 64 | 65 | @keyframes bounce { 66 | 0% { 67 | transform: translateY(0); 68 | } 69 | 70 | 100% { 71 | transform: translateY(-0.2rem); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/search/configuration/SearchConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.search.configuration; 2 | 3 | import jakarta.annotation.PreDestroy; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.context.annotation.Configuration; 7 | 8 | import java.io.File; 9 | import java.io.IOException; 10 | import java.nio.file.Files; 11 | import java.nio.file.Path; 12 | import java.nio.file.Paths; 13 | 14 | @Configuration 15 | public class SearchConfiguration { 16 | 17 | private static final String LUCENE_INDEX_DIR = "./lucene/indexes"; 18 | private static final Logger log = LoggerFactory.getLogger(SearchConfiguration.class); 19 | 20 | @PreDestroy 21 | public void cleanUp() { 22 | try { 23 | Path directory = Paths.get(LUCENE_INDEX_DIR); 24 | if (Files.exists(directory)) { 25 | deleteDirectoryRecursively(directory); 26 | log.info("Lucene index directory cleared successfully."); 27 | } 28 | } catch (IOException e) { 29 | log.error("Error while clearing Lucene index directory: {}", e.getMessage()); 30 | } 31 | } 32 | 33 | private void deleteDirectoryRecursively(Path path) throws IOException { 34 | Files.walk(path) 35 | .sorted((path1, path2) -> path2.compareTo(path1)) // Sort in reverse order to delete files before directories 36 | .map(Path::toFile) 37 | .forEach(File::delete); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/notifications/controller/NotificationsController.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.notifications.controller; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.PathVariable; 7 | import org.springframework.web.bind.annotation.PutMapping; 8 | import org.springframework.web.bind.annotation.RequestAttribute; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | import com.linkedin.backend.features.authentication.model.User; 13 | import com.linkedin.backend.features.notifications.model.Notification; 14 | import com.linkedin.backend.features.notifications.service.NotificationService; 15 | 16 | @RestController 17 | @RequestMapping("/api/v1/notifications") 18 | public class NotificationsController { 19 | private final NotificationService notificationService; 20 | 21 | public NotificationsController(NotificationService notificationService) { 22 | this.notificationService = notificationService; 23 | } 24 | 25 | @GetMapping 26 | public List getUserNotifications(@RequestAttribute("authenticatedUser") User user) { 27 | return notificationService.getUserNotifications(user); 28 | } 29 | 30 | @PutMapping("/{notificationId}") 31 | public Notification markNotificationAsRead(@PathVariable Long notificationId) { 32 | return notificationService.markNotificationAsRead(notificationId); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/features/profile/components/Activity/Activity.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { request } from "../../../../utils/api"; 4 | import { IUser } from "../../../authentication/contexts/AuthenticationContextProvider"; 5 | import { IPost, Post } from "../../../feed/components/Post/Post"; 6 | import classes from "./Activity.module.scss"; 7 | interface IActivityProps { 8 | user: IUser | null; 9 | authUser: IUser | null; 10 | id: string | undefined; 11 | } 12 | export function Activity({ user, authUser, id }: IActivityProps) { 13 | const [posts, setPosts] = useState([]); 14 | useEffect(() => { 15 | request({ 16 | endpoint: `/api/v1/feed/posts/user/${id}`, 17 | onSuccess: (data) => setPosts(data), 18 | onFailure: (error) => console.log(error), 19 | }); 20 | }, [id]); 21 | return ( 22 |
23 |

Latest post

24 |
25 | {posts.length > 0 ? ( 26 | <> 27 | 32 | 33 | 34 | See more 35 | 36 | 37 | ) : ( 38 | <>{authUser?.id == user?.id ? "You have no posts yet." : "This user has no posts yet."} 39 | )} 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/components/AuthenticationLayout/AuthenticationLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | import classes from "./AuthenticationLayout.module.scss"; 3 | 4 | export function AuthenticationLayout() { 5 | return ( 6 |
7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 |
15 | 16 |
17 | 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/features/feed/components/Modal/Modal.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | position: fixed; 3 | inset: 0; 4 | background-color: rgb(0 0 0 / 50%); 5 | display: flex; 6 | justify-content: center; 7 | align-items: flex-start; 8 | z-index: 9999; 9 | 10 | .modal { 11 | background-color: white; 12 | border-radius: 0.3rem; 13 | border: 1px solid #e0e0e0; 14 | width: 100%; 15 | max-width: 45rem; 16 | margin: 4.5rem 1rem; 17 | padding: 1rem; 18 | } 19 | 20 | .header { 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | margin-bottom: 1rem; 25 | } 26 | 27 | button.close, 28 | button.cancel { 29 | background-color: #ececec; 30 | width: 2rem; 31 | height: 2rem; 32 | border-radius: 50%; 33 | display: grid; 34 | place-items: center; 35 | } 36 | 37 | button.cancel { 38 | margin-left: auto; 39 | margin-bottom: 0.5rem; 40 | } 41 | 42 | .preview { 43 | position: relative; 44 | 45 | img { 46 | width: 100%; 47 | max-height: 12rem; 48 | } 49 | 50 | button.cancel { 51 | position: absolute; 52 | top: 0.5rem; 53 | right: 0.5rem; 54 | z-index: 1; 55 | display: none; 56 | } 57 | 58 | &:hover button.cancel { 59 | display: grid; 60 | } 61 | } 62 | 63 | .error { 64 | color: red; 65 | } 66 | 67 | .title { 68 | font-weight: bold; 69 | } 70 | 71 | textarea { 72 | width: 100%; 73 | height: 15rem; 74 | resize: none; 75 | border: 1px solid #e0e0e0; 76 | padding: 1rem; 77 | margin-bottom: -1rem; 78 | border-radius: 0.3rem; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /frontend/src/utils/api.ts: -------------------------------------------------------------------------------- 1 | const BASE_URL = import.meta.env.VITE_API_URL; 2 | 3 | interface IRequestParams { 4 | endpoint: string; 5 | method?: "GET" | "POST" | "PUT" | "DELETE"; 6 | contentType?: "application/json" | "multipart/form-data"; 7 | body?: BodyInit | FormData; 8 | onSuccess: (data: T) => void; 9 | onFailure: (error: string) => void; 10 | } 11 | 12 | interface IHeaders extends Record { 13 | Authorization: string; 14 | } 15 | 16 | export const request = async ({ 17 | endpoint, 18 | method = "GET", 19 | body, 20 | contentType = "application/json", 21 | onSuccess, 22 | onFailure, 23 | }: IRequestParams): Promise => { 24 | const headers: IHeaders = { 25 | Authorization: `Bearer ${localStorage.getItem("token")}`, 26 | }; 27 | 28 | if (contentType === "application/json") { 29 | headers["Content-Type"] = "application/json"; 30 | } 31 | 32 | try { 33 | const response = await fetch(`${BASE_URL}${endpoint}`, { 34 | method, 35 | headers, 36 | body, 37 | }); 38 | 39 | if (!response.ok) { 40 | if (response.status === 401 && !window.location.pathname.includes("authentication")) { 41 | window.location.href = "/authentication/login"; 42 | return; 43 | } 44 | 45 | const { message } = await response.json(); 46 | throw new Error(message); 47 | } 48 | 49 | const data: T = await response.json(); 50 | onSuccess(data); 51 | } catch (error) { 52 | if (error instanceof Error) { 53 | onFailure(error.message); 54 | } else { 55 | onFailure("An error occurred. Please try again later."); 56 | } 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /backend/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | java 3 | id("org.springframework.boot") version "3.3.4" 4 | id("io.spring.dependency-management") version "1.1.6" 5 | } 6 | 7 | group = "com.linkedin" 8 | version = "0.0.1-SNAPSHOT" 9 | 10 | java { 11 | toolchain { 12 | languageVersion = JavaLanguageVersion.of(21) 13 | } 14 | } 15 | 16 | repositories { 17 | mavenCentral() 18 | } 19 | 20 | dependencies { 21 | implementation("org.springframework.boot:spring-boot-starter-web") 22 | implementation("org.springframework.boot:spring-boot-starter-validation") 23 | implementation("org.springframework.boot:spring-boot-starter-mail") 24 | implementation("org.springframework.boot:spring-boot-starter-websocket") 25 | 26 | // Database 27 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 28 | implementation("mysql:mysql-connector-java:8.0.33") 29 | 30 | // Search 31 | implementation("org.hibernate.search:hibernate-search-mapper-orm:7.2.2.Final") 32 | implementation("org.hibernate.search:hibernate-search-backend-lucene:7.2.2.Final") 33 | implementation("org.jboss.logging:jboss-logging:3.6.1.Final") 34 | 35 | // Security 36 | implementation("io.jsonwebtoken:jjwt-api:0.12.6") 37 | implementation("io.jsonwebtoken:jjwt-impl:0.12.6") 38 | implementation(("io.jsonwebtoken:jjwt-jackson:0.12.6")) 39 | 40 | // DevTools 41 | developmentOnly("org.springframework.boot:spring-boot-devtools") 42 | 43 | // Testing 44 | testImplementation("org.springframework.boot:spring-boot-starter-test") 45 | testRuntimeOnly("org.junit.platform:junit-platform-launcher") 46 | } 47 | 48 | tasks.withType { 49 | useJUnitPlatform() 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/features/profile/components/Header/Header.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | background-color: white; 3 | border-radius: 0.3rem; 4 | border: 1px solid #e0e0e0; 5 | overflow: hidden; 6 | padding-bottom: 1rem; 7 | 8 | .wrapper { 9 | display: grid; 10 | grid-template-columns: 1fr auto; 11 | padding-inline: 1rem; 12 | } 13 | 14 | .cover-wrapper { 15 | position: relative; 16 | 17 | > button { 18 | position: absolute; 19 | right: 1rem; 20 | top: 1rem; 21 | } 22 | 23 | .cover { 24 | height: 12rem; 25 | width: 100%; 26 | } 27 | } 28 | 29 | .buttons { 30 | display: flex; 31 | gap: 0.5rem; 32 | justify-content: flex-end; 33 | } 34 | 35 | button:not(.connect, .avatar) { 36 | background-color: #f5f5f5; 37 | width: 2rem; 38 | height: 2rem; 39 | border-radius: 50%; 40 | display: grid; 41 | place-items: center; 42 | transition: background-color 0.3s; 43 | 44 | &:hover { 45 | background-color: #e0e0e0; 46 | } 47 | } 48 | 49 | .connect { 50 | margin-bottom: 0; 51 | } 52 | 53 | svg { 54 | width: 1rem; 55 | } 56 | 57 | .avatar { 58 | width: 7rem; 59 | height: 7rem; 60 | border-radius: 50%; 61 | margin-top: -3rem; 62 | border: 4px solid black; 63 | overflow: hidden; 64 | position: relative; 65 | margin-left: 1rem; 66 | 67 | img { 68 | width: 100%; 69 | height: 100%; 70 | } 71 | } 72 | 73 | .name { 74 | margin-top: 0.5rem; 75 | font-weight: bold; 76 | } 77 | 78 | .location { 79 | color: #585858; 80 | } 81 | 82 | .inputs { 83 | display: grid; 84 | gap: 1rem; 85 | grid-template-columns: 1fr 1fr; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/messaging/model/Conversation.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.messaging.model; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | import com.linkedin.backend.features.authentication.model.User; 7 | 8 | import jakarta.persistence.CascadeType; 9 | import jakarta.persistence.Entity; 10 | import jakarta.persistence.GeneratedValue; 11 | import jakarta.persistence.GenerationType; 12 | import jakarta.persistence.Id; 13 | import jakarta.persistence.ManyToOne; 14 | import jakarta.persistence.OneToMany; 15 | 16 | @Entity(name = "conversations") 17 | public class Conversation { 18 | @Id 19 | @GeneratedValue(strategy = GenerationType.IDENTITY) 20 | private Long id; 21 | 22 | @ManyToOne(optional = false) 23 | private User author; 24 | 25 | @ManyToOne(optional = false) 26 | private User recipient; 27 | 28 | @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) 29 | private List messages = new ArrayList<>(); 30 | 31 | public Conversation() { 32 | } 33 | 34 | public Conversation(User author, User recipient) { 35 | this.author = author; 36 | this.recipient = recipient; 37 | } 38 | 39 | public Long getId() { 40 | return id; 41 | } 42 | 43 | public void setId(Long id) { 44 | this.id = id; 45 | } 46 | 47 | public User getAuthor() { 48 | return author; 49 | } 50 | 51 | public void setAuthor(User author) { 52 | this.author = author; 53 | } 54 | 55 | public User getRecipient() { 56 | return recipient; 57 | } 58 | 59 | public void setRecipient(User recipient) { 60 | this.recipient = recipient; 61 | } 62 | 63 | public List getMessages() { 64 | return messages; 65 | } 66 | 67 | public void setMessages(List messages) { 68 | this.messages = messages; 69 | } 70 | } -------------------------------------------------------------------------------- /frontend/src/features/feed/components/Comment/Comment.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | font-size: 0.8rem; 4 | gap: 0.3rem; 5 | position: relative; 6 | 7 | &:not(:last-child) { 8 | margin-bottom: 0.5rem; 9 | padding-bottom: 0.5rem; 10 | border-bottom: 1px solid #e0e0e0; 11 | } 12 | 13 | .header { 14 | display: flex; 15 | justify-content: space-between; 16 | gap: 1rem; 17 | align-items: flex-start; 18 | } 19 | 20 | button.author { 21 | display: grid; 22 | align-items: center; 23 | grid-template-columns: 4rem 1fr; 24 | gap: 1rem; 25 | text-align: left; 26 | margin-bottom: 0.5rem; 27 | 28 | > div { 29 | width: 100%; 30 | } 31 | } 32 | 33 | button.action { 34 | background-color: transparent; 35 | width: 1.5rem; 36 | height: 1.5rem; 37 | border-radius: 50%; 38 | display: grid; 39 | place-items: center; 40 | transition: 0.3s; 41 | 42 | &:hover, 43 | &.active { 44 | background-color: #e0e0e0; 45 | } 46 | 47 | svg { 48 | width: 0.8rem; 49 | height: 0.8rem; 50 | } 51 | } 52 | 53 | .actions { 54 | position: absolute; 55 | right: 0.5rem; 56 | top: 1.7rem; 57 | display: flex; 58 | flex-direction: column; 59 | align-items: flex-start; 60 | background-color: #e0e0e0; 61 | border-radius: 0.3rem; 62 | padding: 0.5rem; 63 | font-size: 0.8rem; 64 | gap: 0.5rem; 65 | 66 | button { 67 | width: 100%; 68 | text-align: left; 69 | } 70 | 71 | button:not(:last-child) { 72 | border-bottom: 1px solid #ccc; 73 | padding-bottom: 0.3rem; 74 | } 75 | } 76 | 77 | .name { 78 | font-weight: bold; 79 | display: flex; 80 | justify-content: space-between; 81 | align-items: center; 82 | gap: 1rem; 83 | } 84 | 85 | .avatar { 86 | width: 4rem; 87 | height: 4rem; 88 | border-radius: 50%; 89 | object-fit: cover; 90 | flex-shrink: 0; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/storage/controller/StorageController.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.storage.controller; 2 | 3 | import java.io.FileInputStream; 4 | import java.io.IOException; 5 | 6 | import org.springframework.http.HttpHeaders; 7 | import org.springframework.http.MediaType; 8 | import org.springframework.http.ResponseEntity; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RestController; 13 | import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; 14 | 15 | import com.linkedin.backend.features.storage.service.StorageService; 16 | 17 | @RestController 18 | @RequestMapping("/api/v1/storage") 19 | public class StorageController { 20 | private final StorageService storageService; 21 | 22 | public StorageController(StorageService storageService) { 23 | this.storageService = storageService; 24 | } 25 | 26 | @GetMapping("/{filename}") 27 | public ResponseEntity serveFile(@PathVariable String filename) throws IOException { 28 | MediaType mediaType = storageService.getMediaType(filename); 29 | FileInputStream resource = storageService.getFileInputStream(filename); 30 | 31 | StreamingResponseBody stream = outputStream -> { 32 | try (resource) { 33 | int nRead; 34 | byte[] data = new byte[1024]; 35 | while ((nRead = resource.read(data, 0, data.length)) != -1) { 36 | outputStream.write(data, 0, nRead); 37 | outputStream.flush(); 38 | } 39 | } 40 | }; 41 | 42 | return ResponseEntity 43 | .ok() 44 | .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + filename + "\"") 45 | .contentType(mediaType) 46 | .body(stream); 47 | } 48 | } -------------------------------------------------------------------------------- /frontend/src/features/messaging/pages/Messages/Messaging.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Outlet, useLocation, useNavigate } from "react-router-dom"; 3 | import { usePageTitle } from "../../../../hooks/usePageTitle"; 4 | import { RightSidebar } from "../../../feed/components/RightSidebar/RightSidebar"; 5 | import { Conversations } from "../../components/Conversations/Conversations"; 6 | import classes from "./Messaging.module.scss"; 7 | 8 | export function Messaging() { 9 | usePageTitle("Messaging"); 10 | const [windowWidth, setWindowWidth] = useState(window.innerWidth); 11 | const location = useLocation(); 12 | const creatingNewConversation = location.pathname.includes("new"); 13 | const onConversation = location.pathname.includes("conversations"); 14 | const navigate = useNavigate(); 15 | useEffect(() => { 16 | const handleResize = () => setWindowWidth(window.innerWidth); 17 | window.addEventListener("resize", handleResize); 18 | 19 | return () => window.removeEventListener("resize", handleResize); 20 | }, []); 21 | 22 | return ( 23 |
24 |
25 |
= 1024 || !creatingNewConversation ? "block" : "none", 29 | }} 30 | > 31 |
32 |

Messaging

33 | 41 |
42 | 47 |
48 | 49 | 50 |
51 |
52 | 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/networking/model/Connection.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.networking.model; 2 | 3 | import com.linkedin.backend.features.authentication.model.User; 4 | import jakarta.persistence.*; 5 | import jakarta.validation.constraints.NotNull; 6 | import org.hibernate.annotations.CreationTimestamp; 7 | 8 | import java.time.LocalDateTime; 9 | 10 | @Entity(name = "connections") 11 | public class Connection { 12 | @Id 13 | @GeneratedValue(strategy = GenerationType.IDENTITY) 14 | private Long id; 15 | 16 | @ManyToOne 17 | @JoinColumn(name = "author_id", nullable = false) 18 | private User author; 19 | 20 | @ManyToOne 21 | @JoinColumn(name = "recipient_id", nullable = false) 22 | private User recipient; 23 | 24 | @NotNull 25 | private Status status = Status.PENDING; 26 | 27 | private Boolean seen = false; 28 | 29 | @CreationTimestamp 30 | private LocalDateTime connectionDate; 31 | 32 | public Connection() { 33 | } 34 | 35 | public Connection(User author, User recipient) { 36 | this.author = author; 37 | this.recipient = recipient; 38 | } 39 | 40 | public Long getId() { 41 | return id; 42 | } 43 | 44 | 45 | public LocalDateTime getConnectionDate() { 46 | return connectionDate; 47 | } 48 | 49 | public void setConnectionDate(LocalDateTime connectionDate) { 50 | this.connectionDate = connectionDate; 51 | } 52 | 53 | public @NotNull Status getStatus() { 54 | return status; 55 | } 56 | 57 | public void setStatus(@NotNull Status status) { 58 | this.status = status; 59 | } 60 | 61 | public User getAuthor() { 62 | return author; 63 | } 64 | 65 | public void setAuthor(User author) { 66 | this.author = author; 67 | } 68 | 69 | public User getRecipient() { 70 | return recipient; 71 | } 72 | 73 | public void setRecipient(User recipient) { 74 | this.recipient = recipient; 75 | } 76 | 77 | public Boolean getSeen() { 78 | return seen; 79 | } 80 | 81 | public void setSeen(Boolean seen) { 82 | this.seen = seen; 83 | } 84 | } -------------------------------------------------------------------------------- /frontend/src/features/networking/pages/Connections/Connections.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { request } from "../../../../utils/api"; 3 | import { useAuthentication } from "../../../authentication/contexts/AuthenticationContextProvider"; 4 | import { useWebSocket } from "../../../ws/WebSocketContextProvider"; 5 | import { Connection, IConnection } from "../../components/Connection/Connection"; 6 | import { Title } from "../../components/Title/Title"; 7 | import classes from "./Connections.module.scss"; 8 | export function Connections() { 9 | const [connexions, setConnections] = useState([]); 10 | const { user } = useAuthentication(); 11 | const ws = useWebSocket(); 12 | 13 | useEffect(() => { 14 | request({ 15 | endpoint: "/api/v1/networking/connections", 16 | onSuccess: (data) => setConnections(data), 17 | onFailure: (error) => console.log(error), 18 | }); 19 | }, []); 20 | 21 | useEffect(() => { 22 | const subscription = ws?.subscribe( 23 | "/topic/users/" + user?.id + "/connections/accepted", 24 | (data) => { 25 | const connection = JSON.parse(data.body); 26 | setConnections((connections) => connections.filter((c) => c.id !== connection.id)); 27 | } 28 | ); 29 | 30 | return () => subscription?.unsubscribe(); 31 | }, [user?.id, ws]); 32 | 33 | useEffect(() => { 34 | const subscription = ws?.subscribe( 35 | "/topic/users/" + user?.id + "/connections/remove", 36 | (data) => { 37 | const connection = JSON.parse(data.body); 38 | setConnections((connections) => connections.filter((c) => c.id !== connection.id)); 39 | } 40 | ); 41 | 42 | return () => subscription?.unsubscribe(); 43 | }, [user?.id, ws]); 44 | 45 | return ( 46 |
47 | Connections ({connexions.length}) 48 | 49 | <> 50 | {connexions.map((connection) => ( 51 | 57 | ))} 58 | {connexions.length === 0 &&
No connections yet.
} 59 | 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/feed/model/Comment.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.feed.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.linkedin.backend.features.authentication.model.User; 5 | import jakarta.persistence.*; 6 | import org.hibernate.annotations.CreationTimestamp; 7 | 8 | import java.time.LocalDateTime; 9 | 10 | @Entity(name = "comments") 11 | public class Comment { 12 | 13 | @Id 14 | @GeneratedValue(strategy = GenerationType.IDENTITY) 15 | private Long id; 16 | @ManyToOne 17 | @JoinColumn(name = "post_id", nullable = false) 18 | @JsonIgnore 19 | private Post post; 20 | @ManyToOne 21 | @JoinColumn(name = "author_id", nullable = false) 22 | private User author; 23 | @Column(nullable = false) 24 | private String content; 25 | 26 | @CreationTimestamp 27 | private LocalDateTime creationDate; 28 | 29 | private LocalDateTime updatedDate; 30 | 31 | public Comment() { 32 | } 33 | 34 | public Comment(Post post, User author, String content) { 35 | this.post = post; 36 | this.author = author; 37 | this.content = content; 38 | } 39 | 40 | @PreUpdate 41 | public void preUpdate() { 42 | this.updatedDate = LocalDateTime.now(); 43 | } 44 | 45 | public Long getId() { 46 | return id; 47 | } 48 | 49 | public void setId(Long id) { 50 | this.id = id; 51 | } 52 | 53 | public Post getPost() { 54 | return post; 55 | } 56 | 57 | public void setPost(Post post) { 58 | this.post = post; 59 | } 60 | 61 | public User getAuthor() { 62 | return author; 63 | } 64 | 65 | public void setAuthor(User author) { 66 | this.author = author; 67 | } 68 | 69 | public String getContent() { 70 | return content; 71 | } 72 | 73 | public void setContent(String content) { 74 | this.content = content; 75 | } 76 | 77 | public LocalDateTime getCreationDate() { 78 | return creationDate; 79 | } 80 | 81 | public void setCreationDate(LocalDateTime creationDate) { 82 | this.creationDate = creationDate; 83 | } 84 | 85 | public LocalDateTime getUpdatedDate() { 86 | return updatedDate; 87 | } 88 | 89 | public void setUpdatedDate(LocalDateTime updatedDate) { 90 | this.updatedDate = updatedDate; 91 | } 92 | } -------------------------------------------------------------------------------- /frontend/src/features/networking/pages/Network/Network.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | 4 | .content { 5 | display: grid; 6 | gap: 1rem; 7 | } 8 | 9 | .suggestions, 10 | .sidebar { 11 | background-color: white; 12 | border-radius: 0.3rem; 13 | border: 1px solid #e0e0e0; 14 | padding: 1rem; 15 | } 16 | 17 | .sidebar { 18 | display: none; 19 | 20 | .buttons { 21 | display: grid; 22 | gap: 0.4rem; 23 | } 24 | 25 | a { 26 | display: flex; 27 | align-items: center; 28 | gap: 0.5rem; 29 | 30 | &:not(:last-child) { 31 | border-bottom: 1px solid #e0e0e0; 32 | margin-bottom: 0.5rem; 33 | padding-bottom: 0.5rem; 34 | } 35 | 36 | &.active { 37 | color: var(--primary-color); 38 | } 39 | } 40 | 41 | svg { 42 | width: 1.5rem; 43 | height: 1.5rem; 44 | } 45 | 46 | .stat { 47 | margin-left: auto; 48 | font-weight: bold; 49 | width: 2rem; 50 | height: 2rem; 51 | border-radius: 50%; 52 | display: flex; 53 | justify-content: center; 54 | align-items: center; 55 | background-color: #f5f5f5; 56 | } 57 | } 58 | 59 | .suggestions { 60 | .list { 61 | display: grid; 62 | gap: 1rem; 63 | grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); 64 | } 65 | 66 | .suggestion { 67 | border-radius: 0.3rem; 68 | border: 1px solid #e0e0e0; 69 | text-align: center; 70 | font-size: 0.9rem; 71 | 72 | .cover { 73 | margin-bottom: -3rem; 74 | height: 5rem; 75 | width: 100%; 76 | } 77 | 78 | .avatar { 79 | width: 6rem; 80 | height: 6rem; 81 | border-radius: 50%; 82 | margin-inline: auto; 83 | } 84 | 85 | .info { 86 | padding: 1rem; 87 | } 88 | 89 | .name { 90 | font-weight: bold; 91 | } 92 | 93 | .connect { 94 | width: max-content; 95 | margin: 0.5rem auto; 96 | } 97 | } 98 | } 99 | 100 | @media screen and (width >= 1024px) { 101 | grid-template-columns: 20rem 1fr; 102 | gap: 1rem; 103 | align-items: flex-start; 104 | 105 | .sidebar { 106 | display: block; 107 | height: max-content; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/messaging/controller/MessagingController.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.messaging.controller; 2 | 3 | import com.linkedin.backend.dto.Response; 4 | import com.linkedin.backend.features.authentication.model.User; 5 | import com.linkedin.backend.features.messaging.dto.MessageDto; 6 | import com.linkedin.backend.features.messaging.model.Conversation; 7 | import com.linkedin.backend.features.messaging.model.Message; 8 | import com.linkedin.backend.features.messaging.service.MessagingService; 9 | import org.springframework.web.bind.annotation.*; 10 | 11 | import java.util.List; 12 | 13 | @RestController 14 | @RequestMapping("/api/v1/messaging") 15 | public class MessagingController { 16 | private final MessagingService messagingService; 17 | 18 | public MessagingController(MessagingService messagingService) { 19 | this.messagingService = messagingService; 20 | } 21 | 22 | @GetMapping("/conversations") 23 | public List getConversations(@RequestAttribute("authenticatedUser") User user) { 24 | return messagingService.getConversationsOfUser(user); 25 | } 26 | 27 | @GetMapping("/conversations/{conversationId}") 28 | public Conversation getConversation(@RequestAttribute("authenticatedUser") User user, @PathVariable Long conversationId) { 29 | return messagingService.getConversation(user, conversationId); 30 | } 31 | 32 | @PostMapping("/conversations") 33 | public Conversation createConversationAndAddMessage(@RequestAttribute("authenticatedUser") User sender, @RequestBody MessageDto messageDto) { 34 | return messagingService.createConversationAndAddMessage(sender, messageDto.receiverId(), messageDto.content()); 35 | } 36 | 37 | @PostMapping("/conversations/{conversationId}/messages") 38 | public Message addMessageToConversation(@RequestAttribute("authenticatedUser") User sender, @RequestBody MessageDto messageDto, @PathVariable Long conversationId) { 39 | return messagingService.addMessageToConversation(conversationId, sender, messageDto.receiverId(), 40 | messageDto.content()); 41 | } 42 | 43 | @PutMapping("/conversations/messages/{messageId}") 44 | public Response markMessageAsRead(@RequestAttribute("authenticatedUser") User user, @PathVariable Long messageId) { 45 | messagingService.markMessageAsRead(user, messageId); 46 | return new Response("Message marked as read"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/features/profile/pages/Profile/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { Loader } from "../../../../components/Loader/Loader"; 4 | import { usePageTitle } from "../../../../hooks/usePageTitle"; 5 | import { request } from "../../../../utils/api"; 6 | import { 7 | IUser, 8 | useAuthentication, 9 | } from "../../../authentication/contexts/AuthenticationContextProvider"; 10 | import { RightSidebar } from "../../../feed/components/RightSidebar/RightSidebar"; 11 | import { About } from "../../components/About/About"; 12 | import { Activity } from "../../components/Activity/Activity"; 13 | import { Header } from "../../components/Header/Header"; 14 | import classes from "./Profile.module.scss"; 15 | export function Profile() { 16 | const { id } = useParams(); 17 | const [loading, setLoading] = useState(true); 18 | const { user: authUser, setUser:setAuthUser } = useAuthentication(); 19 | const [user, setUser] = useState(null); 20 | 21 | usePageTitle(user?.firstName + " " + user?.lastName); 22 | 23 | useEffect(() => { 24 | setLoading(true); 25 | if (id == authUser?.id) { 26 | setUser(authUser); 27 | setLoading(false); 28 | } else { 29 | request({ 30 | endpoint: `/api/v1/authentication/users/${id}`, 31 | onSuccess: (data) => { 32 | setUser(data); 33 | setLoading(false); 34 | }, 35 | onFailure: (error) => console.log(error), 36 | }); 37 | } 38 | }, [authUser, id]); 39 | 40 | if (loading) { 41 | return ; 42 | } 43 | 44 | return ( 45 |
46 |
47 |
setAuthUser(user)} /> 48 | setAuthUser(user)} /> 49 | 50 | 51 |
52 |

Experience

53 |

TODO

54 |
55 |
56 |

Education

57 |

TODO

58 |
59 |
60 |

Skills

61 |

TODO

62 |
63 |
64 |
65 | 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /frontend/src/components/Header/components/Search/Search.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { IUser } from "../../../../features/authentication/contexts/AuthenticationContextProvider"; 4 | import { request } from "../../../../utils/api"; 5 | import { Input } from "../../../Input/Input"; 6 | import classes from "./Search.module.scss"; 7 | 8 | export function Search() { 9 | const [searchTerm, setSearchTerm] = useState(""); 10 | const [suggestions, setSuggestions] = useState([]); 11 | const navigate = useNavigate(); 12 | 13 | useEffect(() => { 14 | const fetchSuggestions = async () => { 15 | if (searchTerm.length > 0) { 16 | request({ 17 | endpoint: "/api/v1/search/users?query=" + searchTerm, 18 | onSuccess: (data) => setSuggestions(data), 19 | onFailure: (error) => console.log("Search error:", error), 20 | }); 21 | } else { 22 | setSuggestions([]); 23 | } 24 | }; 25 | fetchSuggestions(); 26 | // const delayDebounceFn = setTimeout(fetchSuggestions, 300); 27 | // return () => clearTimeout(delayDebounceFn); 28 | }, [searchTerm]); 29 | 30 | return ( 31 |
32 | setSearchTerm(e.target.value)} 34 | placeholder="Search for connections" 35 | size="medium" 36 | value={searchTerm} 37 | /> 38 | {suggestions.length > 0 && ( 39 |
    40 | {suggestions.map((user) => ( 41 |
  • 42 | 60 |
  • 61 | ))} 62 |
63 | )} 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /frontend/src/features/profile/pages/Posts/Posts.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { Loader } from "../../../../components/Loader/Loader"; 4 | import { usePageTitle } from "../../../../hooks/usePageTitle"; 5 | import { request } from "../../../../utils/api"; 6 | import { 7 | IUser, 8 | useAuthentication, 9 | } from "../../../authentication/contexts/AuthenticationContextProvider"; 10 | import { LeftSidebar } from "../../../feed/components/LeftSidebar/LeftSidebar"; 11 | import { IPost, Post } from "../../../feed/components/Post/Post"; 12 | import { RightSidebar } from "../../../feed/components/RightSidebar/RightSidebar"; 13 | import classes from "./Posts.module.scss"; 14 | export function Posts() { 15 | const { id } = useParams(); 16 | const [posts, setPosts] = useState([]); 17 | const { user: authUser } = useAuthentication(); 18 | const [user, setUser] = useState(null); 19 | const [loading, setLoading] = useState(true); 20 | usePageTitle("Posts | " + user?.firstName + " " + user?.lastName); 21 | useEffect(() => { 22 | if (id == authUser?.id) { 23 | setUser(authUser); 24 | setLoading(false); 25 | } else { 26 | request({ 27 | endpoint: `/api/v1/authentication/users/${id}`, 28 | onSuccess: (data) => { 29 | setUser(data); 30 | setLoading(false); 31 | }, 32 | onFailure: (error) => console.log(error), 33 | }); 34 | } 35 | }, [authUser, id]); 36 | 37 | useEffect(() => { 38 | request({ 39 | endpoint: `/api/v1/feed/posts/user/${id}`, 40 | onSuccess: (data) => setPosts(data), 41 | onFailure: (error) => console.log(error), 42 | }); 43 | }, [id]); 44 | 45 | if (loading) { 46 | return ; 47 | } 48 | return ( 49 |
50 |
51 | 52 |
53 |
54 |

{user?.firstName + " " + user?.lastName + "'s posts"}

55 | {posts.map((post) => ( 56 | 57 | ))} 58 | {posts.length === 0 && ( 59 |
60 |

No post to display.

61 |
62 | )} 63 |
64 |
65 | 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/notifications/model/Notification.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.notifications.model; 2 | 3 | import java.time.LocalDateTime; 4 | 5 | import org.hibernate.annotations.CreationTimestamp; 6 | 7 | import com.linkedin.backend.features.authentication.model.User; 8 | 9 | import jakarta.persistence.Entity; 10 | import jakarta.persistence.GeneratedValue; 11 | import jakarta.persistence.GenerationType; 12 | import jakarta.persistence.Id; 13 | import jakarta.persistence.ManyToOne; 14 | 15 | @Entity 16 | public class Notification { 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.IDENTITY) 19 | private Long id; 20 | @ManyToOne 21 | private User recipient; 22 | @ManyToOne 23 | private User actor; 24 | private boolean isRead; 25 | private NotificationType type; 26 | private Long resourceId; 27 | 28 | @CreationTimestamp 29 | private LocalDateTime creationDate; 30 | 31 | public Notification(User actor, User recipient, NotificationType type, Long resourceId) { 32 | this.actor = actor; 33 | this.recipient = recipient; 34 | this.type = type; 35 | this.isRead = false; 36 | this.resourceId = resourceId; 37 | } 38 | 39 | public Notification() { 40 | 41 | } 42 | 43 | public Long getId() { 44 | return id; 45 | } 46 | 47 | public void setId(Long id) { 48 | this.id = id; 49 | } 50 | 51 | public boolean isRead() { 52 | return isRead; 53 | } 54 | 55 | public void setRead(boolean read) { 56 | isRead = read; 57 | } 58 | 59 | public User getRecipient() { 60 | return recipient; 61 | } 62 | 63 | public void setRecipient(User recipient) { 64 | this.recipient = recipient; 65 | } 66 | 67 | public User getActor() { 68 | return actor; 69 | } 70 | 71 | public void setActor(User actor) { 72 | this.actor = actor; 73 | } 74 | 75 | public NotificationType getType() { 76 | return type; 77 | } 78 | 79 | public void setType(NotificationType type) { 80 | this.type = type; 81 | } 82 | 83 | public Long getResourceId() { 84 | return resourceId; 85 | } 86 | 87 | public void setResourceId(Long resourceId) { 88 | this.resourceId = resourceId; 89 | } 90 | 91 | public LocalDateTime getCreationDate() { 92 | return creationDate; 93 | } 94 | 95 | public void setCreationDate(LocalDateTime creationDate) { 96 | this.creationDate = creationDate; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/networking/controller/ConnectionController.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.networking.controller; 2 | 3 | import com.linkedin.backend.features.authentication.model.User; 4 | import com.linkedin.backend.features.networking.model.Connection; 5 | import com.linkedin.backend.features.networking.model.Status; 6 | import com.linkedin.backend.features.networking.service.ConnectionService; 7 | import org.springframework.web.bind.annotation.*; 8 | 9 | import java.util.List; 10 | 11 | @RestController 12 | @RequestMapping("/api/v1/networking") 13 | public class ConnectionController { 14 | private final ConnectionService connectionService; 15 | 16 | public ConnectionController(ConnectionService connectionService) { 17 | this.connectionService = connectionService; 18 | } 19 | 20 | @GetMapping("/connections") 21 | public List getUserConnections(@RequestAttribute("authenticatedUser") User user, @RequestParam(required = false) Status status, @RequestParam(required = false) Long userId) { 22 | if (userId != null) { 23 | return connectionService.getUserConnections(userId, status); 24 | } 25 | return connectionService.getUserConnections(user, status); 26 | } 27 | 28 | @PostMapping("/connections") 29 | public Connection sendConnectionRequest(@RequestAttribute("authenticatedUser") User sender, @RequestParam Long recipientId) { 30 | return connectionService.sendConnectionRequest(sender, recipientId); 31 | } 32 | 33 | @PutMapping("/connections/{id}") 34 | public Connection acceptConnectionRequest(@RequestAttribute("authenticatedUser") User recipient, @PathVariable Long id) { 35 | return connectionService.acceptConnectionRequest(recipient, id); 36 | } 37 | 38 | @DeleteMapping("/connections/{id}") 39 | public Connection rejectOrCancelConnection(@RequestAttribute("authenticatedUser") User recipient, @PathVariable Long id) { 40 | return connectionService.rejectOrCancelConnection(recipient, id); 41 | } 42 | 43 | @PutMapping("/connections/{id}/seen") 44 | public Connection markConnectionAsSeen(@RequestAttribute("authenticatedUser") User user, @PathVariable Long id) { 45 | return connectionService.markConnectionAsSeen(user, id); 46 | } 47 | 48 | @GetMapping("/suggestions") 49 | public List getConnectionSuggestions(@RequestAttribute("authenticatedUser") User user, @RequestParam(required = false, defaultValue = "6") Integer limit) { 50 | return connectionService.getRecommendations(user.getId(), limit); 51 | } 52 | } -------------------------------------------------------------------------------- /frontend/src/features/messaging/components/Conversations/Conversations.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, useEffect, useState } from "react"; 2 | import { request } from "../../../../utils/api"; 3 | import { 4 | IUser, 5 | useAuthentication, 6 | } from "../../../authentication/contexts/AuthenticationContextProvider"; 7 | import { useWebSocket } from "../../../ws/WebSocketContextProvider"; 8 | import { IMessage } from "../Messages/Messages"; 9 | import classes from "./Conversations.module.scss"; 10 | import { Conversation } from "./components/Conversation/Conversation"; 11 | 12 | export interface IConversation { 13 | id: number; 14 | author: IUser; 15 | recipient: IUser; 16 | messages: IMessage[]; 17 | } 18 | 19 | // We need an interface starting with "I" instead of a Type to have consistency with the rest of the codebase. 20 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 21 | interface IConversationsProps extends HTMLAttributes {} 22 | 23 | export function Conversations(props: IConversationsProps) { 24 | const [conversations, setConversations] = useState([]); 25 | const { user } = useAuthentication(); 26 | const websocketClient = useWebSocket(); 27 | 28 | useEffect(() => { 29 | request({ 30 | endpoint: "/api/v1/messaging/conversations", 31 | onSuccess: (data) => setConversations(data), 32 | onFailure: (error) => console.log(error), 33 | }); 34 | }, []); 35 | 36 | useEffect(() => { 37 | const subscription = websocketClient?.subscribe( 38 | `/topic/users/${user?.id}/conversations`, 39 | (message) => { 40 | const conversation = JSON.parse(message.body); 41 | setConversations((prevConversations) => { 42 | const index = prevConversations.findIndex((c) => c.id === conversation.id); 43 | if (index === -1) { 44 | return [conversation, ...prevConversations]; 45 | } 46 | return prevConversations.map((c) => (c.id === conversation.id ? conversation : c)); 47 | }); 48 | } 49 | ); 50 | return () => subscription?.unsubscribe(); 51 | }, [user?.id, websocketClient]); 52 | 53 | return ( 54 |
55 | {conversations.map((conversation) => { 56 | return ; 57 | })} 58 | {conversations.length === 0 && ( 59 |
65 | No conversation to display. 66 |
67 | )} 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/components/Header/Header.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | border: 1px solid #e0e0e0; 3 | background-color: white; 4 | color: rgb(0 0 0 / 60%); 5 | font-size: 0.9rem; 6 | position: sticky; 7 | top: 0; 8 | z-index: 100; 9 | 10 | .container { 11 | max-width: 74rem; 12 | padding-inline: 1rem; 13 | margin: 0 auto; 14 | width: 100%; 15 | display: grid; 16 | grid-template-columns: 1fr auto; 17 | position: relative; 18 | gap: 2rem; 19 | } 20 | 21 | .logo { 22 | width: 3rem; 23 | height: 3rem; 24 | fill: var(--primary-color); 25 | } 26 | 27 | .left { 28 | display: grid; 29 | gap: 1rem; 30 | grid-template-columns: auto 1fr; 31 | align-items: center; 32 | } 33 | 34 | .right { 35 | display: flex; 36 | align-items: center; 37 | gap: 1rem; 38 | } 39 | 40 | ul.navigation { 41 | position: absolute; 42 | top: 5rem; 43 | right: 1rem; 44 | background-color: white; 45 | border: 1px solid #e0e0e0; 46 | border-radius: 0.3rem; 47 | padding: 1rem; 48 | width: min(18rem, 100%); 49 | display: grid; 50 | gap: 0.5rem; 51 | } 52 | 53 | a { 54 | display: flex; 55 | align-items: center; 56 | gap: 1rem; 57 | 58 | &:hover { 59 | color: black; 60 | } 61 | } 62 | 63 | .active { 64 | color: var(--primary-color); 65 | } 66 | 67 | svg { 68 | width: 1.5rem; 69 | height: 1.5rem; 70 | } 71 | 72 | .toggle { 73 | display: flex; 74 | flex-direction: column; 75 | align-items: center; 76 | } 77 | 78 | .notifications, 79 | .messaging, 80 | .network { 81 | position: relative; 82 | } 83 | 84 | .badge { 85 | position: absolute; 86 | top: -0.5rem; 87 | left: 0.5rem; 88 | background-color: crimson; 89 | color: white; 90 | border-radius: 50%; 91 | font-size: 0.6rem; 92 | width: 1rem; 93 | height: 1rem; 94 | display: grid; 95 | place-items: center; 96 | } 97 | 98 | @media screen and (width >= 1080px) { 99 | .left { 100 | grid-template-columns: auto 0.7fr 0.3fr; 101 | } 102 | 103 | .right { 104 | gap: 2rem; 105 | 106 | ul a { 107 | flex-direction: column; 108 | gap: 0; 109 | } 110 | 111 | ul.navigation { 112 | position: relative; 113 | display: flex; 114 | align-items: center; 115 | width: auto; 116 | gap: 2rem; 117 | background-color: transparent; 118 | border: none; 119 | top: 0; 120 | right: 0; 121 | padding: 0; 122 | } 123 | 124 | .toggle { 125 | display: none; 126 | } 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/hooks/useOauth.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; 3 | import { useAuthentication } from "../contexts/AuthenticationContextProvider"; 4 | 5 | const GOOGLE_OAUTH2_CLIENT_ID = import.meta.env.VITE_GOOGLE_OAUTH_CLIENT_ID; 6 | const VITE_GOOGLE_OAUTH_URL = import.meta.env.VITE_GOOGLE_OAUTH_URL; 7 | 8 | export function useOauth(page: "login" | "signup") { 9 | const [searchParams, setSearchParams] = useSearchParams(); 10 | const navigate = useNavigate(); 11 | const location = useLocation(); 12 | const { ouathLogin } = useAuthentication(); 13 | const code = searchParams.get("code"); 14 | const state = searchParams.get("state"); 15 | const error = searchParams.get("error"); 16 | const [isOauthInProgress, setIsOauthInProgress] = useState(code !== null || error !== null); 17 | const [oauthError, setOauthError] = useState(""); 18 | 19 | useEffect(() => { 20 | async function fetchData() { 21 | if (error) { 22 | if (error === "access_denied") { 23 | setOauthError("You denied access to your Google account."); 24 | } else { 25 | setOauthError("An unknown error occurred."); 26 | } 27 | setIsOauthInProgress(false); 28 | setSearchParams({}); 29 | return; 30 | } 31 | 32 | if (!code || !state) return; 33 | 34 | const { destination, antiForgeryToken } = JSON.parse(state); 35 | 36 | if (antiForgeryToken !== "n6kibcv2ov") { 37 | setOauthError("Invalid state parameter."); 38 | setIsOauthInProgress(false); 39 | setSearchParams({}); 40 | return; 41 | } 42 | 43 | try { 44 | setTimeout(async () => { 45 | await ouathLogin(code, page); 46 | setIsOauthInProgress(false); 47 | setSearchParams({}); 48 | navigate(destination || "/"); 49 | }, 1000); 50 | } catch (error) { 51 | if (error instanceof Error) { 52 | setOauthError(error.message); 53 | } else { 54 | setOauthError("An unknown error occurred."); 55 | } 56 | setIsOauthInProgress(false); 57 | setSearchParams({}); 58 | } 59 | } 60 | fetchData(); 61 | }, [code, error, navigate, ouathLogin, page, setSearchParams, state]); 62 | 63 | return { 64 | isOauthInProgress, 65 | oauthError, 66 | startOauth: () => { 67 | const redirectUri = `${window.location.origin}/authentication/${page}`; 68 | window.location.href = `${VITE_GOOGLE_OAUTH_URL}?client_id=${GOOGLE_OAUTH2_CLIENT_ID}&redirect_uri=${redirectUri}&scope=openid+email+profile&response_type=code&state=${JSON.stringify( 69 | { antiForgeryToken: "n6kibcv2ov", destination: location.state?.from || "/" } 70 | )}`; 71 | }, 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/messaging/model/Message.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.messaging.model; 2 | 3 | import java.time.LocalDateTime; 4 | 5 | import org.hibernate.annotations.CreationTimestamp; 6 | 7 | import com.fasterxml.jackson.annotation.JsonIgnore; 8 | import com.linkedin.backend.features.authentication.model.User; 9 | 10 | import jakarta.persistence.Entity; 11 | import jakarta.persistence.GeneratedValue; 12 | import jakarta.persistence.GenerationType; 13 | import jakarta.persistence.Id; 14 | import jakarta.persistence.ManyToOne; 15 | 16 | @Entity(name = "messages") 17 | public class Message { 18 | @Id 19 | @GeneratedValue(strategy = GenerationType.IDENTITY) 20 | private Long id; 21 | 22 | @ManyToOne(optional = false) 23 | private User sender; 24 | 25 | @ManyToOne(optional = false) 26 | private User receiver; 27 | 28 | @JsonIgnore 29 | @ManyToOne(optional = false) 30 | private Conversation conversation; 31 | 32 | private String content; 33 | private Boolean isRead = false; 34 | 35 | @CreationTimestamp 36 | private LocalDateTime createdAt; 37 | 38 | public Message() { 39 | } 40 | 41 | public Message(User sender, User receiver, Conversation conversation, String content) { 42 | this.sender = sender; 43 | this.receiver = receiver; 44 | this.conversation = conversation; 45 | this.content = content; 46 | this.isRead = false; 47 | } 48 | 49 | public void setId(Long id) { 50 | this.id = id; 51 | } 52 | 53 | public Long getId() { 54 | return id; 55 | } 56 | 57 | public User getReceiver() { 58 | return receiver; 59 | } 60 | 61 | public void setReceiver(User receiver) { 62 | this.receiver = receiver; 63 | } 64 | 65 | public User getSender() { 66 | return sender; 67 | } 68 | 69 | public void setSender(User sender) { 70 | this.sender = sender; 71 | } 72 | 73 | public String getContent() { 74 | return content; 75 | } 76 | 77 | public void setContent(String content) { 78 | this.content = content; 79 | } 80 | 81 | public LocalDateTime getCreatedAt() { 82 | return createdAt; 83 | } 84 | 85 | public void setCreatedAt(LocalDateTime createdAt) { 86 | this.createdAt = createdAt; 87 | } 88 | 89 | public Boolean getIsRead() { 90 | return isRead; 91 | } 92 | 93 | public void setIsRead(Boolean read) { 94 | isRead = read; 95 | } 96 | 97 | public Conversation getConversation() { 98 | return conversation; 99 | } 100 | 101 | public void setConversation(Conversation conversation) { 102 | this.conversation = conversation; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/components/Conversations/components/Conversation/Conversation.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate, useParams } from "react-router-dom"; 3 | import { useAuthentication } from "../../../../../authentication/contexts/AuthenticationContextProvider"; 4 | import { useWebSocket } from "../../../../../ws/WebSocketContextProvider"; 5 | import { IConversation } from "../../Conversations"; 6 | import classes from "./Conversation.module.scss"; 7 | 8 | interface ConversationItemProps { 9 | conversation: IConversation; 10 | } 11 | 12 | export function Conversation(props: ConversationItemProps) { 13 | const { user } = useAuthentication(); 14 | const navigate = useNavigate(); 15 | const { id } = useParams(); 16 | const ws = useWebSocket(); 17 | const [conversation, setConversation] = useState(props.conversation); 18 | 19 | const conversationUserToDisplay = 20 | conversation.recipient.id === user?.id ? conversation.author : conversation.recipient; 21 | const unreadMessagesCount = conversation.messages.filter( 22 | (message) => message.receiver.id === user?.id && !message.isRead 23 | ).length; 24 | 25 | useEffect(() => { 26 | const subscription = ws?.subscribe( 27 | `/topic/conversations/${conversation.id}/messages`, 28 | (data) => { 29 | const message = JSON.parse(data.body); 30 | setConversation((prevConversation) => { 31 | const index = prevConversation.messages.findIndex((m) => m.id === message.id); 32 | if (index == -1) { 33 | return { 34 | ...prevConversation, 35 | messages: [...prevConversation.messages, message], 36 | }; 37 | } 38 | 39 | return { 40 | ...prevConversation, 41 | messages: prevConversation.messages.map((m) => (m.id === message.id ? message : m)), 42 | }; 43 | }); 44 | return () => subscription?.unsubscribe(); 45 | } 46 | ); 47 | }, [conversation?.id, ws]); 48 | 49 | return ( 50 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Running the project on your machine 4 | 5 | ### Prerequisites 6 | 7 | Node.js (version 22 or compatible), npm (version 10 or compatible), 8 | Java JDK (version 21), and Docker (version 24.0.7 or compatible). 9 | 10 | #### Backend Setup 11 | 12 | Navigate to the backend directory: 13 | 14 | ``` 15 | cd backend 16 | ``` 17 | 18 | Run the docker containers: 19 | 20 | ``` 21 | docker-compose up 22 | ``` 23 | 24 | Set up continuous build: 25 | 26 | _Mac/Linux:_ 27 | 28 | ``` 29 | ./gradlew build -t -x test 30 | ``` 31 | 32 | _Windows:_ 33 | 34 | ``` 35 | gradlew.bat build -t -x test 36 | ``` 37 | 38 | Configure environment variables for OAuth 2.0 and OIDC, aka the Continue with Google button. Skip if you do not want to test this feature: 39 | 40 | _Mac/Linux:_ 41 | 42 | ``` 43 | export OAUTH_GOOGLE_CLIENT_ID=your_google_client_id 44 | export OAUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret 45 | ``` 46 | 47 | _Windows:_ 48 | 49 | ``` 50 | set OAUTH_GOOGLE_CLIENT_ID=your_google_client_id 51 | set OAUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret 52 | ``` 53 | 54 | Run the backend: 55 | 56 | _Mac/Linux:_ 57 | 58 | ``` 59 | ./gradlew bootRun 60 | ``` 61 | 62 | _Windows:_ 63 | 64 | ``` 65 | gradlew.bat bootRun 66 | ``` 67 | 68 | #### Frontend Setup 69 | 70 | Navigate to the frontend directory: 71 | 72 | ``` 73 | cd frontend 74 | ``` 75 | 76 | Set up the necessary environment variables: 77 | 78 | _Mac/Linux:_ 79 | 80 | ``` 81 | cp .env.example .env 82 | ``` 83 | 84 | _Windows:_ 85 | 86 | ``` 87 | copy .env.example .env 88 | ``` 89 | 90 | ⚠️: make sure all variables are populated. Leave `VITE_GOOGLE_OAUTH_CLIENT_ID` as is if you do not want to test Oauth 2.0 and OIDC. 91 | 92 | Install dependencies: 93 | 94 | ``` 95 | npm install 96 | ``` 97 | 98 | Run the frontend in development mode: 99 | 100 | ``` 101 | npm run dev 102 | ``` 103 | 104 | You can access the backend at `http://localhost:8080`, the frontend at `http://localhost:5173`, and the Mailhog SMTP server UI at `http://localhost:8025`. 105 | 106 | The database hostname is `127.0.0.1`, the port is `3306`, and the root password is `root`. 107 | 108 | ### Github Actions 109 | 110 | To test the CI/CD workflows locally, you can use the `act` tool. First, ensure you have `act` installed. Then, create a file named `event.json` with the following content (to simulate modifications to both frontend and backend): 111 | 112 | ```json 113 | { 114 | "repository": { 115 | "default_branch": "main" 116 | }, 117 | "push": { 118 | "base_ref": "refs/heads/main", 119 | "commits": [ 120 | { 121 | "modified": ["frontend/some-file.js", "backend/some-file.java"] 122 | } 123 | ] 124 | } 125 | } 126 | ``` 127 | 128 | Run the following command to simulate a push event: 129 | 130 | ``` 131 | act -e event.json 132 | ``` 133 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/storage/service/StorageService.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.storage.service; 2 | 3 | import java.io.FileInputStream; 4 | import java.io.IOException; 5 | import java.nio.file.Files; 6 | import java.nio.file.Path; 7 | import java.nio.file.Paths; 8 | import java.util.UUID; 9 | 10 | import org.springframework.http.MediaType; 11 | import org.springframework.stereotype.Service; 12 | import org.springframework.web.multipart.MultipartFile; 13 | 14 | @Service 15 | public class StorageService { 16 | private final Path rootLocation = Paths.get("uploads"); 17 | 18 | public StorageService() { 19 | if (!rootLocation.toFile().exists()) { 20 | rootLocation.toFile().mkdir(); 21 | } 22 | } 23 | 24 | public String saveImage(MultipartFile file) throws IOException { 25 | if (!isImage(file.getContentType())) { 26 | throw new IllegalArgumentException("File is not an image"); 27 | } 28 | 29 | if (isFileTooLarge(file)) { 30 | throw new IllegalArgumentException("File is too large"); 31 | } 32 | 33 | String fileExtension = getFileExtension(file.getOriginalFilename()); 34 | String fileName = UUID.randomUUID() + fileExtension; 35 | Files.copy(file.getInputStream(), this.rootLocation.resolve(fileName)); 36 | return fileName; 37 | } 38 | 39 | public FileInputStream getFileInputStream(String filename) throws IOException { 40 | Path file = rootLocation.resolve(filename); 41 | 42 | if (!file.getParent().equals(rootLocation)) { // Prevent directory traversal 43 | throw new IllegalArgumentException("Invalid file path"); 44 | } 45 | 46 | if (!file.toFile().exists()) { 47 | throw new IllegalArgumentException("File not found"); 48 | } 49 | 50 | return new FileInputStream(file.toFile()); 51 | } 52 | 53 | public void deleteFile(String fileName) throws IOException { 54 | Path file = rootLocation.resolve(fileName); 55 | Files.delete(file); 56 | } 57 | 58 | public MediaType getMediaType(String filename) { 59 | String extension = getFileExtension(filename); 60 | switch (extension) { 61 | case ".png": 62 | return MediaType.IMAGE_PNG; 63 | case ".jpg": 64 | case ".jpeg": 65 | return MediaType.IMAGE_JPEG; 66 | case ".gif": 67 | return MediaType.IMAGE_GIF; 68 | default: 69 | return MediaType.APPLICATION_OCTET_STREAM; 70 | } 71 | } 72 | 73 | private String getFileExtension(String filename) { 74 | return filename.substring(filename.lastIndexOf(".")); 75 | } 76 | 77 | private boolean isImage(String contentType) { 78 | return contentType.startsWith("image"); 79 | } 80 | 81 | private boolean isFileTooLarge(MultipartFile file) { 82 | return file.getSize() > 10 * 1024 * 1024; // 10MB 83 | } 84 | 85 | } -------------------------------------------------------------------------------- /frontend/src/features/feed/components/Post/Post.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | background-color: white; 3 | border-radius: 0.3rem; 4 | border: 1px solid #e0e0e0; 5 | margin-bottom: 1rem; 6 | position: relative; 7 | 8 | .top { 9 | display: flex; 10 | gap: 1rem; 11 | align-items: flex-start; 12 | font-size: 0.9rem; 13 | padding: 1rem; 14 | justify-content: space-between; 15 | 16 | .avatar { 17 | width: 4rem; 18 | min-width: 4rem; 19 | height: 4rem; 20 | border-radius: 50%; 21 | } 22 | 23 | .name { 24 | font-weight: bold; 25 | } 26 | 27 | .name, 28 | .title, 29 | .date { 30 | display: -webkit-box; 31 | -webkit-line-clamp: 1; 32 | line-clamp: 1; 33 | -webkit-box-orient: vertical; 34 | overflow: hidden; 35 | text-overflow: ellipsis; 36 | } 37 | } 38 | 39 | .author { 40 | display: flex; 41 | gap: 0.5rem; 42 | align-items: center; 43 | } 44 | 45 | .picture { 46 | width: 100%; 47 | } 48 | 49 | .actions { 50 | display: flex; 51 | gap: 1rem; 52 | justify-content: space-between; 53 | padding: 1rem; 54 | border-top: 1px solid #e0e0e0; 55 | 56 | button { 57 | display: flex; 58 | align-items: center; 59 | gap: 0.5rem; 60 | font-size: 0.9rem; 61 | 62 | svg { 63 | width: 1rem; 64 | height: 1rem; 65 | } 66 | 67 | &.active { 68 | color: var(--primary-color); 69 | } 70 | 71 | &[disabled] { 72 | cursor: wait; 73 | } 74 | } 75 | } 76 | 77 | .comments { 78 | padding: 0 1rem 1rem; 79 | border-top: 1px solid #e0e0e0; 80 | } 81 | 82 | button.toggle { 83 | background-color: transparent; 84 | width: 1.5rem; 85 | height: 1.5rem; 86 | border-radius: 50%; 87 | display: grid; 88 | place-items: center; 89 | transition: 0.3s; 90 | 91 | &:hover, 92 | &.active { 93 | background-color: #e0e0e0; 94 | } 95 | 96 | svg { 97 | width: 0.8rem; 98 | height: 0.8rem; 99 | } 100 | } 101 | 102 | .menu { 103 | position: absolute; 104 | right: 1.2rem; 105 | top: 2.7rem; 106 | display: flex; 107 | flex-direction: column; 108 | align-items: flex-start; 109 | background-color: #e0e0e0; 110 | border-radius: 0.3rem; 111 | padding: 0.5rem; 112 | font-size: 0.8rem; 113 | gap: 0.5rem; 114 | 115 | button { 116 | width: 100%; 117 | text-align: left; 118 | } 119 | 120 | button:not(:last-child) { 121 | border-bottom: 1px solid #ccc; 122 | padding-bottom: 0.3rem; 123 | } 124 | } 125 | 126 | .content { 127 | padding: 0 1rem 1rem; 128 | } 129 | 130 | .stats { 131 | display: flex; 132 | justify-content: space-between; 133 | align-items: center; 134 | 135 | .stat { 136 | padding: 0.3rem 1rem; 137 | font-size: 0.8rem; 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /frontend/public/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/VerifyEmail/VerifyEmail.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../../../components/Button/Button"; 2 | import { Input } from "../../../../components/Input/Input"; 3 | import { usePageTitle } from "../../../../hooks/usePageTitle"; 4 | import { Box } from "../../components/Box/Box"; 5 | import classes from "./VerifyEmail.module.scss"; 6 | 7 | import { useState } from "react"; 8 | import { useNavigate } from "react-router-dom"; 9 | import { request } from "../../../../utils/api"; 10 | import { useAuthentication } from "../../contexts/AuthenticationContextProvider"; 11 | 12 | export function VerifyEmail() { 13 | const [errorMessage, setErrorMessage] = useState(""); 14 | const { user, setUser } = useAuthentication(); 15 | const [message, setMessage] = useState(""); 16 | const [isLoading, setIsLoading] = useState(false); 17 | usePageTitle("Verify Email"); 18 | const navigate = useNavigate(); 19 | 20 | const validateEmail = async (code: string) => { 21 | setMessage(""); 22 | await request({ 23 | endpoint: `/api/v1/authentication/validate-email-verification-token?token=${code}`, 24 | method: "PUT", 25 | onSuccess: () => { 26 | setErrorMessage(""); 27 | setUser({ ...user!, emailVerified: true }); 28 | navigate("/"); 29 | }, 30 | onFailure: (error) => { 31 | setErrorMessage(error); 32 | }, 33 | }); 34 | setIsLoading(false); 35 | }; 36 | 37 | const sendEmailVerificationToken = async () => { 38 | setErrorMessage(""); 39 | 40 | await request({ 41 | endpoint: `/api/v1/authentication/send-email-verification-token`, 42 | onSuccess: () => { 43 | setErrorMessage(""); 44 | setMessage("Code sent successfully. Please check your email."); 45 | }, 46 | onFailure: (error) => { 47 | setErrorMessage(error); 48 | }, 49 | }); 50 | setIsLoading(false); 51 | }; 52 | 53 | return ( 54 |
55 | 56 |

Verify your Email

57 | 58 |
{ 60 | e.preventDefault(); 61 | setIsLoading(true); 62 | const code = e.currentTarget.code.value; 63 | await validateEmail(code); 64 | setIsLoading(false); 65 | }} 66 | > 67 |

Only one step left to complete your registration. Verify your email address.

68 | 69 | {message ?

{message}

: null} 70 | {errorMessage ?

{errorMessage}

: null} 71 | 74 | 84 |
85 |
86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/features/networking/pages/Invitations/Invitations.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { request } from "../../../../utils/api"; 3 | import { useAuthentication } from "../../../authentication/contexts/AuthenticationContextProvider"; 4 | import { useWebSocket } from "../../../ws/WebSocketContextProvider"; 5 | import { Connection, IConnection } from "../../components/Connection/Connection"; 6 | import { Title } from "../../components/Title/Title"; 7 | import classes from "./Invitations.module.scss"; 8 | export function Invitations() { 9 | const [connexions, setConnections] = useState([]); 10 | const [sent, setSent] = useState(false); 11 | const { user } = useAuthentication(); 12 | const filtredConnections = sent 13 | ? connexions.filter((c) => c.author.id === user?.id) 14 | : connexions.filter((c) => c.recipient.id === user?.id); 15 | const ws = useWebSocket(); 16 | 17 | useEffect(() => { 18 | request({ 19 | endpoint: "/api/v1/networking/connections?status=PENDING", 20 | onSuccess: (data) => setConnections(data), 21 | onFailure: (error) => console.log(error), 22 | }); 23 | }, [user?.id]); 24 | 25 | useEffect(() => { 26 | const subscription = ws?.subscribe("/topic/users/" + user?.id + "/connections/new", (data) => { 27 | const connection = JSON.parse(data.body); 28 | setConnections((connections) => [connection, ...connections]); 29 | }); 30 | 31 | return () => subscription?.unsubscribe(); 32 | }, [user?.id, ws]); 33 | 34 | useEffect(() => { 35 | const subscription = ws?.subscribe( 36 | "/topic/users/" + user?.id + "/connections/accepted", 37 | (data) => { 38 | const connection = JSON.parse(data.body); 39 | setConnections((connections) => connections.filter((c) => c.id !== connection.id)); 40 | } 41 | ); 42 | 43 | return () => subscription?.unsubscribe(); 44 | }, [user?.id, ws]); 45 | 46 | useEffect(() => { 47 | const subscription = ws?.subscribe( 48 | "/topic/users/" + user?.id + "/connections/remove", 49 | (data) => { 50 | const connection = JSON.parse(data.body); 51 | setConnections((connections) => connections.filter((c) => c.id !== connection.id)); 52 | } 53 | ); 54 | 55 | return () => subscription?.unsubscribe(); 56 | }, [user?.id, ws]); 57 | 58 | return ( 59 |
60 | Invitations ({connexions.length}) 61 |
62 | 65 | 68 |
69 | {filtredConnections.map((connection) => ( 70 | 76 | ))} 77 | {filtredConnections.length === 0 && ( 78 |
No invitation {sent ? "sent" : "received"} yet.
79 | )} 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /backend/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /frontend/src/features/feed/components/RightSidebar/RightSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate, useParams } from "react-router-dom"; 3 | import { Button } from "../../../../components/Button/Button"; 4 | import { Loader } from "../../../../components/Loader/Loader"; 5 | import { request } from "../../../../utils/api"; 6 | import { IUser } from "../../../authentication/contexts/AuthenticationContextProvider"; 7 | import { IConnection } from "../../../networking/components/Connection/Connection"; 8 | import classes from "./RightSidebar.module.scss"; 9 | export function RightSidebar() { 10 | const [suggestions, setSuggestions] = useState([]); 11 | const [loading, setLoading] = useState(true); 12 | const navigate = useNavigate(); 13 | const { id } = useParams(); 14 | 15 | useEffect(() => { 16 | request({ 17 | endpoint: "/api/v1/networking/suggestions?limit=2", 18 | onSuccess: (data) => { 19 | if (id) { 20 | setSuggestions(data.filter((s) => s.id !== id)); 21 | } else { 22 | setSuggestions(data); 23 | } 24 | }, 25 | onFailure: (error) => console.log(error), 26 | }).then(() => setLoading(false)); 27 | }, [id]); 28 | 29 | return ( 30 |
31 |

Add to your connexions

32 |
33 | {suggestions.map((suggestion) => { 34 | return ( 35 |
36 | 42 |
43 | 51 | 68 |
69 |
70 | ); 71 | })} 72 | 73 | {suggestions.length === 0 && !loading && ( 74 |
75 |

No suggestions available at the moment.

76 |
77 | )} 78 | {loading && } 79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/features/feed/components/LeftSidebar/LeftSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { request } from "../../../../utils/api"; 4 | import { IUser } from "../../../authentication/contexts/AuthenticationContextProvider"; 5 | import { IConnection } from "../../../networking/components/Connection/Connection"; 6 | import { useWebSocket } from "../../../ws/WebSocketContextProvider"; 7 | import classes from "./LeftSidebar.module.scss"; 8 | interface ILeftSidebarProps { 9 | user: IUser | null; 10 | } 11 | export function LeftSidebar({ user }: ILeftSidebarProps) { 12 | const [connections, setConnections] = useState([]); 13 | const ws = useWebSocket(); 14 | const navigate = useNavigate(); 15 | 16 | useEffect(() => { 17 | request({ 18 | endpoint: "/api/v1/networking/connections?userId=" + user?.id, 19 | onSuccess: (data) => setConnections(data), 20 | onFailure: (error) => console.log(error), 21 | }); 22 | }, [user?.id]); 23 | 24 | useEffect(() => { 25 | const subscription = ws?.subscribe( 26 | "/topic/users/" + user?.id + "/connections/accepted", 27 | (data) => { 28 | const connection = JSON.parse(data.body); 29 | setConnections((connections) => [...connections, connection]); 30 | } 31 | ); 32 | 33 | return () => subscription?.unsubscribe(); 34 | }, [user?.id, ws]); 35 | 36 | useEffect(() => { 37 | const subscription = ws?.subscribe( 38 | "/topic/users/" + user?.id + "/connections/remove", 39 | (data) => { 40 | const connection = JSON.parse(data.body); 41 | setConnections((connections) => connections.filter((c) => c.id !== connection.id)); 42 | } 43 | ); 44 | 45 | return () => subscription?.unsubscribe(); 46 | }, [user?.id, ws]); 47 | 48 | return ( 49 |
50 |
51 | Cover 59 |
60 | 70 |
{user?.firstName + " " + user?.lastName}
71 |
{user?.position + " at " + user?.company}
72 |
73 | {/*
74 |
Profile viewers
75 |
0
76 |
*/} 77 | 83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/components/Messages/components/Message/Message.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { request } from "../../../../../../utils/api"; 3 | import { IUser } from "../../../../../authentication/contexts/AuthenticationContextProvider"; 4 | import { TimeAgo } from "../../../../../feed/components/TimeAgo/TimeAgo"; 5 | import { IMessage } from "../../Messages"; 6 | import classes from "./Message.module.scss"; 7 | 8 | interface IMessageProps { 9 | message: IMessage; 10 | user: IUser | null; 11 | } 12 | 13 | export function Message({ message, user }: IMessageProps) { 14 | const messageRef = useRef(null); 15 | useEffect(() => { 16 | if (!message.isRead && user?.id === message.receiver.id) { 17 | request({ 18 | endpoint: `/api/v1/messaging/conversations/messages/${message.id}`, 19 | method: "PUT", 20 | onSuccess: () => {}, 21 | onFailure: (error) => console.log(error), 22 | }); 23 | } 24 | }, [message.id, message.isRead, message.receiver.id, user?.id]); 25 | 26 | useEffect(() => { 27 | messageRef.current?.scrollIntoView(); 28 | }, []); 29 | return ( 30 |
36 |
37 |
38 | {`${message.sender.firstName} 43 |
44 |
45 | {message.sender.firstName} {message.sender.lastName} 46 |
47 | 48 | 49 |
50 |
51 |
{message.content}
52 |
53 | {message.sender.id == user?.id && ( 54 |
55 | {!message.isRead ? ( 56 | <> 57 | 58 | 59 | 60 | Sent 61 | 62 | ) : ( 63 | <> 64 | 65 | 66 | 67 | Read 68 | 69 | )} 70 |
71 | )} 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/authentication/filter/AuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.authentication.filter; 2 | 3 | import com.linkedin.backend.features.authentication.model.User; 4 | import com.linkedin.backend.features.authentication.service.AuthenticationService; 5 | import com.linkedin.backend.features.authentication.utils.JsonWebToken; 6 | import jakarta.servlet.FilterChain; 7 | import jakarta.servlet.ServletException; 8 | import jakarta.servlet.http.HttpFilter; 9 | import jakarta.servlet.http.HttpServletRequest; 10 | import jakarta.servlet.http.HttpServletResponse; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.io.IOException; 14 | import java.util.Arrays; 15 | import java.util.List; 16 | 17 | @Component 18 | public class AuthenticationFilter extends HttpFilter { 19 | private final List unsecuredEndpoints = Arrays.asList( 20 | "/api/v1/authentication/login", 21 | "/api/v1/authentication/register", 22 | "/api/v1/authentication/send-password-reset-token", 23 | "/api/v1/authentication/reset-password"); 24 | 25 | private final JsonWebToken jsonWebTokenService; 26 | private final AuthenticationService authenticationService; 27 | 28 | public AuthenticationFilter(JsonWebToken jsonWebTokenService, AuthenticationService authenticationService) { 29 | this.jsonWebTokenService = jsonWebTokenService; 30 | this.authenticationService = authenticationService; 31 | } 32 | 33 | @Override 34 | protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) 35 | throws IOException, ServletException { 36 | response.addHeader("Access-Control-Allow-Origin", "*"); 37 | response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); 38 | response.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); 39 | 40 | if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { 41 | response.setStatus(HttpServletResponse.SC_OK); 42 | return; 43 | } 44 | 45 | String path = request.getRequestURI(); 46 | 47 | if (unsecuredEndpoints.contains(path) || path.startsWith("/api/v1/authentication/oauth") || path.startsWith("/api/v1/storage")) { 48 | chain.doFilter(request, response); 49 | return; 50 | } 51 | 52 | try { 53 | String authorization = request.getHeader("Authorization"); 54 | if (authorization == null || !authorization.startsWith("Bearer ")) { 55 | throw new ServletException("Token missing."); 56 | } 57 | 58 | String token = authorization.substring(7); 59 | 60 | if (jsonWebTokenService.isTokenExpired(token)) { 61 | throw new ServletException("Invalid token"); 62 | } 63 | 64 | String email = jsonWebTokenService.getEmailFromToken(token); 65 | User user = authenticationService.getUser(email); 66 | request.setAttribute("authenticatedUser", user); 67 | chain.doFilter(request, response); 68 | } catch (Exception e) { 69 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 70 | response.setContentType("application/json"); 71 | response.getWriter().write("{\"message\": \"Invalid authentication token, or token missing.\"}"); 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /frontend/src/features/profile/components/ProfileAndCoverPictureUpdateModal/ProfileAndCoverPictureUpdateModal.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, RefObject } from "react"; 2 | import classes from "./ProfileAndCoverPictureUpdateModal.module.scss"; 3 | 4 | interface IProfilePictureModalProps { 5 | newPicturePreview: string | null; 6 | setNewPicturePreview: (value: string | null) => void; 7 | setNewPicture: (value: File | null) => void; 8 | fileInputRef: RefObject; 9 | handleFileChange: (e: ChangeEvent) => void; 10 | triggerFileInput: () => void; 11 | updatePicture: () => void; 12 | setEditingPicture: (value: boolean) => void; 13 | type: "profile" | "cover"; 14 | } 15 | 16 | export function ProfileAndCoverPictureUpdateModal({ 17 | newPicturePreview, 18 | setNewPicturePreview, 19 | setNewPicture, 20 | fileInputRef, 21 | handleFileChange, 22 | triggerFileInput, 23 | updatePicture, 24 | setEditingPicture, 25 | type, 26 | }: IProfilePictureModalProps) { 27 | return ( 28 |
29 |
30 |
31 |

{type === "profile" ? "Changing profile picture" : "Changing cover picture"}

32 | 33 |
34 | {type === "profile" ? ( 35 |
36 | 37 |
38 | ) : ( 39 |
40 | 41 |
42 | )} 43 | 50 |
51 | 61 | 66 | 71 |
72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/Login/Login.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useState } from "react"; 2 | import { Link, useLocation, useNavigate } from "react-router-dom"; 3 | import { Button } from "../../../../components/Button/Button"; 4 | import { Input } from "../../../../components/Input/Input"; 5 | import { Loader } from "../../../../components/Loader/Loader"; 6 | import { usePageTitle } from "../../../../hooks/usePageTitle"; 7 | import { Box } from "../../components/Box/Box"; 8 | import { Seperator } from "../../components/Seperator/Seperator"; 9 | import { useAuthentication } from "../../contexts/AuthenticationContextProvider"; 10 | import { useOauth } from "../../hooks/useOauth"; 11 | import classes from "./Login.module.scss"; 12 | 13 | export function Login() { 14 | const [errorMessage, setErrorMessage] = useState(""); 15 | const [isLoading, setIsLoading] = useState(false); 16 | const { login } = useAuthentication(); 17 | const location = useLocation(); 18 | const navigate = useNavigate(); 19 | const { isOauthInProgress, oauthError, startOauth } = useOauth("login"); 20 | usePageTitle("Login"); 21 | 22 | const doLogin = async (e: FormEvent) => { 23 | e.preventDefault(); 24 | setIsLoading(true); 25 | const email = e.currentTarget.email.value; 26 | const password = e.currentTarget.password.value; 27 | 28 | try { 29 | await login(email, password); 30 | const destination = location.state?.from || "/"; 31 | navigate(destination); 32 | } catch (error) { 33 | if (error instanceof Error) { 34 | setErrorMessage(error.message); 35 | } else { 36 | setErrorMessage("An unknown error occurred."); 37 | } 38 | } finally { 39 | setIsLoading(false); 40 | } 41 | }; 42 | 43 | if (isOauthInProgress) { 44 | return ; 45 | } 46 | 47 | return ( 48 |
49 | 50 |

Sign in

51 |

Stay updated on your professional world.

52 |
53 | setErrorMessage("")} /> 54 | setErrorMessage("")} 59 | /> 60 | {errorMessage &&

{errorMessage}

} 61 | 62 | 65 | Forgot password? 66 |
67 | Or 68 |
69 | {oauthError &&

{oauthError}

} 70 | 81 | New to LinkedIn? Join now 82 |
83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/features/profile/components/About/About.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Input } from "../../../../components/Input/Input"; 3 | import { request } from "../../../../utils/api"; 4 | import { IUser } from "../../../authentication/contexts/AuthenticationContextProvider"; 5 | import classes from "./About.module.scss"; 6 | 7 | interface AboutProps { 8 | user: IUser | null; 9 | authUser: IUser | null; 10 | onUpdate: (updatedUser: IUser) => void; 11 | } 12 | 13 | export function About({ user, authUser, onUpdate }: AboutProps) { 14 | const [editingAbout, setEditingAbout] = useState(false); 15 | const [aboutInput, setAboutInput] = useState(authUser?.about || ""); 16 | 17 | async function updateAbout() { 18 | if (!user?.id) return; 19 | 20 | await request({ 21 | endpoint: `/api/v1/authentication/profile/${user.id}/info?about=${aboutInput}`, 22 | method: "PUT", 23 | onSuccess: (data) => { 24 | onUpdate(data); 25 | setEditingAbout(false); 26 | }, 27 | onFailure: (error) => console.log(error), 28 | }); 29 | } 30 | 31 | return ( 32 |
33 |
34 |

About

35 | {authUser?.id === user?.id && ( 36 | <> 37 | {!editingAbout ? ( 38 | 43 | ) : ( 44 |
45 | 55 | 60 |
61 | )} 62 | 63 | )} 64 |
65 | {!editingAbout ? ( 66 |

{user?.about ? user.about : "No information provided."}

67 | ) : ( 68 | setAboutInput(e.target.value)} 71 | placeholder="Write something about yourself..." 72 | /> 73 | )} 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/Signup/Signup.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useState } from "react"; 2 | import { Link, useNavigate } from "react-router-dom"; 3 | import { Button } from "../../../../components/Button/Button.tsx"; 4 | import { Input } from "../../../../components/Input/Input.tsx"; 5 | import { Loader } from "../../../../components/Loader/Loader.tsx"; 6 | import { usePageTitle } from "../../../../hooks/usePageTitle.tsx"; 7 | import { Box } from "../../components/Box/Box"; 8 | import { Seperator } from "../../components/Seperator/Seperator"; 9 | import { useAuthentication } from "../../contexts/AuthenticationContextProvider.tsx"; 10 | import { useOauth } from "../../hooks/useOauth.ts"; 11 | import classes from "./Signup.module.scss"; 12 | 13 | export function Signup() { 14 | const [errorMessage, setErrorMessage] = useState(""); 15 | const [isLoading, setIsLoading] = useState(false); 16 | const { signup } = useAuthentication(); 17 | const navigate = useNavigate(); 18 | usePageTitle("Signup"); 19 | const { isOauthInProgress, oauthError, startOauth } = useOauth("signup"); 20 | const doSignup = async (e: FormEvent) => { 21 | e.preventDefault(); 22 | setIsLoading(true); 23 | const email = e.currentTarget.email.value; 24 | const password = e.currentTarget.password.value; 25 | try { 26 | await signup(email, password); 27 | navigate("/"); 28 | } catch (error) { 29 | if (error instanceof Error) { 30 | setErrorMessage(error.message); 31 | } else { 32 | setErrorMessage("An unknown error occurred."); 33 | } 34 | } finally { 35 | setIsLoading(false); 36 | } 37 | }; 38 | 39 | if (isOauthInProgress) { 40 | return ; 41 | } 42 | 43 | return ( 44 |
45 | 46 |

Sign up

47 |

Make the most of your professional life.

48 |
49 | setErrorMessage("")} /> 50 | 51 | setErrorMessage("")} 56 | /> 57 | {errorMessage &&

{errorMessage}

} 58 |

59 | By clicking Agree & Join or Continue, you agree to LinkedIn's{" "} 60 | User Agreement, Privacy Policy, and{" "} 61 | Cookie Policy. 62 |

63 | 66 |
67 | Or 68 | {oauthError &&

{oauthError}

} 69 | 80 |
81 | Already on LinkedIn? Sign in 82 |
83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/features/messaging/pages/Conversation/Conversation.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | grid-template-rows: auto auto 1fr auto; 4 | height: calc(100vh - 12rem); 5 | 6 | &.new { 7 | height: calc(100vh - 8rem); 8 | grid-template-rows: auto 1fr auto; 9 | } 10 | 11 | .header { 12 | padding: 1rem; 13 | border-bottom: 1px solid #e0e0e0; 14 | } 15 | 16 | button.back { 17 | background-color: #f5f5f5; 18 | width: 2rem; 19 | height: 2rem; 20 | border-radius: 50%; 21 | transition: background-color 0.3s; 22 | display: grid; 23 | place-items: center; 24 | 25 | &:hover { 26 | background-color: #e0e0e0; 27 | } 28 | } 29 | 30 | .name { 31 | font-weight: bold; 32 | } 33 | 34 | .top { 35 | padding: 1rem; 36 | display: grid; 37 | align-items: center; 38 | grid-template-columns: 3rem 1fr; 39 | gap: 0.5rem; 40 | border-bottom: 1px solid #e0e0e0; 41 | border-radius: 0 0 0.3rem 0.3rem; 42 | 43 | .avatar { 44 | width: 3rem; 45 | height: 3rem; 46 | border-radius: 50%; 47 | } 48 | } 49 | 50 | .suggestions { 51 | max-height: 17rem; 52 | overflow-y: auto; 53 | border-radius: 0.3rem; 54 | border: 1px solid #e0e0e0; 55 | 56 | > button { 57 | display: flex; 58 | text-align: left; 59 | align-items: center; 60 | gap: 1rem; 61 | font-size: 0.8rem; 62 | width: 100%; 63 | padding: 0.5rem; 64 | 65 | &:not(:last-child) { 66 | border-bottom: 1px solid #e0e0e0; 67 | } 68 | } 69 | } 70 | 71 | .form { 72 | padding-inline: 1rem; 73 | 74 | .avatar { 75 | width: 3rem; 76 | height: 3rem; 77 | border-radius: 50%; 78 | } 79 | 80 | .top { 81 | font-size: 0.8rem; 82 | grid-template-columns: 3rem 1fr auto; 83 | padding-inline: 0; 84 | } 85 | 86 | .close { 87 | background-color: #f5f5f5; 88 | width: 2rem; 89 | height: 2rem; 90 | border-radius: 50%; 91 | transition: background-color 0.3s; 92 | display: grid; 93 | place-items: center; 94 | 95 | &:hover { 96 | background-color: #e0e0e0; 97 | } 98 | } 99 | 100 | &:not(.new) { 101 | position: relative; 102 | 103 | input { 104 | width: 100%; 105 | height: 4rem; 106 | padding: 1rem; 107 | border: 1px solid #e0e0e0; 108 | border-radius: 0.3rem; 109 | margin-bottom: 1rem; 110 | } 111 | 112 | button.send { 113 | position: absolute; 114 | right: 2rem; 115 | top: 40%; 116 | transform: translateY(-50%); 117 | width: 2rem; 118 | height: 2rem; 119 | border-radius: 50%; 120 | background-color: var(--primary-color); 121 | display: grid; 122 | place-items: center; 123 | color: white; 124 | 125 | svg { 126 | width: 1rem; 127 | height: 1rem; 128 | } 129 | 130 | &[disabled] { 131 | background-color: #e0e0e0; 132 | cursor: not-allowed; 133 | color: black; 134 | } 135 | } 136 | } 137 | } 138 | 139 | @media screen and (width >= 1024px) { 140 | grid-template-rows: auto 1fr auto; 141 | height: calc(100vh - 8rem); 142 | 143 | &.new { 144 | grid-template-rows: 1fr auto; 145 | } 146 | 147 | .header { 148 | display: none; 149 | } 150 | 151 | .welcome { 152 | padding: 1.5rem 2rem; 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /frontend/src/components/Header/components/Profile/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useRef } from "react"; 2 | import { Link, useNavigate } from "react-router-dom"; 3 | import { useAuthentication } from "../../../../features/authentication/contexts/AuthenticationContextProvider"; 4 | import { Button } from "../../../Button/Button"; 5 | import classes from "./Profile.module.scss"; 6 | 7 | interface IProfileProps { 8 | showProfileMenu: boolean; 9 | setShowNavigationMenu: Dispatch>; 10 | setShowProfileMenu: Dispatch>; 11 | } 12 | export function Profile({ 13 | showProfileMenu, 14 | setShowProfileMenu, 15 | setShowNavigationMenu, 16 | }: IProfileProps) { 17 | const { logout, user } = useAuthentication(); 18 | const ref = useRef(null); 19 | const navigate = useNavigate(); 20 | 21 | useEffect(() => { 22 | const handleClick = (e: MouseEvent) => { 23 | if (ref.current && !ref.current.contains(e.target as Node)) { 24 | setShowProfileMenu(false); 25 | } 26 | }; 27 | 28 | document.addEventListener("click", handleClick); 29 | 30 | return () => document.removeEventListener("click", handleClick); 31 | }, [setShowProfileMenu]); 32 | 33 | return ( 34 |
35 | 57 | 58 | {showProfileMenu ? ( 59 |
60 |
61 | 70 |
71 |
{user?.firstName + " " + user?.lastName}
72 |
{user?.position + " at " + user?.company}
73 |
74 |
75 |
76 | 87 | { 90 | e.preventDefault(); 91 | logout(); 92 | }} 93 | > 94 | Sign Out 95 | 96 |
97 |
98 | ) : null} 99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/features/feed/model/Post.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.features.feed.model; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.List; 5 | import java.util.Set; 6 | 7 | import com.fasterxml.jackson.annotation.JsonIgnore; 8 | import org.hibernate.annotations.CreationTimestamp; 9 | 10 | import com.linkedin.backend.features.authentication.model.User; 11 | 12 | import jakarta.persistence.CascadeType; 13 | import jakarta.persistence.Entity; 14 | import jakarta.persistence.GeneratedValue; 15 | import jakarta.persistence.GenerationType; 16 | import jakarta.persistence.Id; 17 | import jakarta.persistence.JoinColumn; 18 | import jakarta.persistence.JoinTable; 19 | import jakarta.persistence.ManyToMany; 20 | import jakarta.persistence.ManyToOne; 21 | import jakarta.persistence.OneToMany; 22 | import jakarta.persistence.PreUpdate; 23 | import jakarta.validation.constraints.NotEmpty; 24 | 25 | @Entity(name = "posts") 26 | public class Post { 27 | 28 | @Id 29 | @GeneratedValue(strategy = GenerationType.IDENTITY) 30 | private Long id; 31 | @NotEmpty 32 | private String content; 33 | private String picture; 34 | @ManyToOne 35 | @JoinColumn(name = "author_id", nullable = false) 36 | private User author; 37 | @JsonIgnore 38 | @ManyToMany 39 | @JoinTable(name = "posts_likes", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "user_id")) 40 | private Set likes; 41 | @JsonIgnore 42 | @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) 43 | private List comments; 44 | @CreationTimestamp 45 | private LocalDateTime creationDate; 46 | 47 | private LocalDateTime updatedDate; 48 | 49 | public Post(String content, User author) { 50 | this.content = content; 51 | this.author = author; 52 | } 53 | 54 | public Post() { 55 | } 56 | 57 | @PreUpdate 58 | public void preUpdate() { 59 | this.updatedDate = LocalDateTime.now(); 60 | } 61 | 62 | public Long getId() { 63 | return id; 64 | } 65 | 66 | public void setId(Long id) { 67 | this.id = id; 68 | } 69 | 70 | public String getContent() { 71 | return content; 72 | } 73 | 74 | public void setContent(String content) { 75 | this.content = content; 76 | } 77 | 78 | public User getAuthor() { 79 | return author; 80 | } 81 | 82 | public void setAuthor(User author) { 83 | this.author = author; 84 | } 85 | 86 | public Set getLikes() { 87 | return likes; 88 | } 89 | 90 | public void setLikes(Set likes) { 91 | this.likes = likes; 92 | } 93 | 94 | public String getPicture() { 95 | return picture; 96 | } 97 | 98 | public void setPicture(String picture) { 99 | this.picture = picture; 100 | } 101 | 102 | public List getComments() { 103 | return comments; 104 | } 105 | 106 | public void setComments(List comments) { 107 | this.comments = comments; 108 | } 109 | 110 | public LocalDateTime getCreationDate() { 111 | return creationDate; 112 | } 113 | 114 | public void setCreationDate(LocalDateTime creationDate) { 115 | this.creationDate = creationDate; 116 | } 117 | 118 | public LocalDateTime getUpdatedDate() { 119 | return updatedDate; 120 | } 121 | 122 | public void setUpdatedDate(LocalDateTime updatedDate) { 123 | this.updatedDate = updatedDate; 124 | } 125 | } -------------------------------------------------------------------------------- /backend/src/main/java/com/linkedin/backend/controller/BackendController.java: -------------------------------------------------------------------------------- 1 | package com.linkedin.backend.controller; 2 | 3 | import org.springframework.dao.DataIntegrityViolationException; 4 | import org.springframework.http.HttpStatus; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.http.converter.HttpMessageNotReadableException; 7 | import org.springframework.web.bind.MethodArgumentNotValidException; 8 | import org.springframework.web.bind.MissingServletRequestParameterException; 9 | import org.springframework.web.bind.annotation.ControllerAdvice; 10 | import org.springframework.web.bind.annotation.ExceptionHandler; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RestController; 13 | import org.springframework.web.servlet.resource.NoResourceFoundException; 14 | 15 | import java.nio.file.NoSuchFileException; 16 | import java.util.Map; 17 | 18 | @ControllerAdvice 19 | @RestController 20 | @RequestMapping("/api/v1") 21 | public class BackendController { 22 | @ExceptionHandler(HttpMessageNotReadableException.class) 23 | public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { 24 | return ResponseEntity.badRequest().body(Map.of("message", "Required request body is missing.")); 25 | } 26 | 27 | @ExceptionHandler(MethodArgumentNotValidException.class) 28 | public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { 29 | StringBuilder errorMessage = new StringBuilder(); 30 | e.getBindingResult().getFieldErrors().forEach(error -> 31 | errorMessage.append(error.getField()).append(": ").append(error.getDefaultMessage()).append("; ") 32 | ); 33 | return ResponseEntity.badRequest().body(Map.of("message", errorMessage.toString())); 34 | } 35 | 36 | 37 | @ExceptionHandler(NoResourceFoundException.class) 38 | public ResponseEntity> handleNoResourceFoundException(NoResourceFoundException e) { 39 | return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", e.getMessage())); 40 | } 41 | 42 | @ExceptionHandler(DataIntegrityViolationException.class) 43 | public ResponseEntity> handleDataIntegrityViolationException(DataIntegrityViolationException e) { 44 | if (e.getMessage().contains("Duplicate entry")) { 45 | return ResponseEntity.badRequest().body(Map.of("message", "Email already exists, please use another email or login.")); 46 | } 47 | return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); 48 | } 49 | 50 | @ExceptionHandler(MissingServletRequestParameterException.class) 51 | public ResponseEntity> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { 52 | return ResponseEntity.badRequest().body(Map.of("message", "Required request parameter is missing.")); 53 | } 54 | 55 | @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class}) 56 | public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { 57 | return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); 58 | } 59 | 60 | @ExceptionHandler(NoSuchFileException.class) 61 | public ResponseEntity> handleNoSuchFileException(NoSuchFileException e) { 62 | return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "File not found")); 63 | } 64 | 65 | @ExceptionHandler(Exception.class) 66 | public ResponseEntity> handleException(Exception e) { 67 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("message", e.getMessage())); 68 | } 69 | } -------------------------------------------------------------------------------- /frontend/src/features/feed/components/Comment/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { Input } from "../../../../components/Input/Input"; 4 | import { 5 | IUser, 6 | useAuthentication, 7 | } from "../../../authentication/contexts/AuthenticationContextProvider"; 8 | 9 | import { TimeAgo } from "../TimeAgo/TimeAgo"; 10 | import classes from "./Comment.module.scss"; 11 | 12 | export interface IComment { 13 | id: number; 14 | content: string; 15 | author: IUser; 16 | creationDate: string; 17 | updatedDate?: string; 18 | } 19 | 20 | interface ICommentProps { 21 | comment: IComment; 22 | deleteComment: (commentId: number) => Promise; 23 | editComment: (commentId: number, content: string) => Promise; 24 | } 25 | 26 | export function Comment({ comment, deleteComment, editComment }: ICommentProps) { 27 | const navigate = useNavigate(); 28 | const [showActions, setShowActions] = useState(false); 29 | const [editing, setEditing] = useState(false); 30 | const [commentContent, setCommentContent] = useState(comment.content); 31 | const { user } = useAuthentication(); 32 | return ( 33 |
34 | {!editing ? ( 35 | <> 36 |
37 | 58 | {comment.author.id == user?.id && ( 59 | 67 | )} 68 | 69 | {showActions && ( 70 |
71 | 72 | 73 |
74 | )} 75 |
76 |
{comment.content}
77 | 78 | ) : ( 79 |
{ 81 | e.preventDefault(); 82 | await editComment(comment.id, commentContent); 83 | setEditing(false); 84 | setShowActions(false); 85 | }} 86 | > 87 | { 91 | setCommentContent(e.target.value); 92 | }} 93 | placeholder="Edit your comment" 94 | /> 95 |
96 | )} 97 |
98 | ); 99 | } 100 | 101 | export default Comment; 102 | -------------------------------------------------------------------------------- /frontend/src/features/feed/pages/Notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { usePageTitle } from "../../../../hooks/usePageTitle"; 4 | import { request } from "../../../../utils/api"; 5 | import { 6 | IUser, 7 | useAuthentication, 8 | } from "../../../authentication/contexts/AuthenticationContextProvider"; 9 | import { LeftSidebar } from "../../components/LeftSidebar/LeftSidebar"; 10 | import { RightSidebar } from "../../components/RightSidebar/RightSidebar"; 11 | import { TimeAgo } from "../../components/TimeAgo/TimeAgo"; 12 | import classes from "./Notifications.module.scss"; 13 | 14 | enum NotificationType { 15 | LIKE = "LIKE", 16 | COMMENT = "COMMENT", 17 | } 18 | export interface INotification { 19 | id: number; 20 | recipient: IUser; 21 | actor: IUser; 22 | read: boolean; 23 | type: NotificationType; 24 | resourceId: number; 25 | creationDate: string; 26 | } 27 | 28 | export function Notifications() { 29 | usePageTitle("Notifications"); 30 | const [notifications, setNotifications] = useState([]); 31 | const { user } = useAuthentication(); 32 | useEffect(() => { 33 | const fetchNotifications = async () => { 34 | await request({ 35 | endpoint: "/api/v1/notifications", 36 | onSuccess: setNotifications, 37 | onFailure: (error) => console.log(error), 38 | }); 39 | }; 40 | 41 | fetchNotifications(); 42 | }, []); 43 | 44 | return ( 45 |
46 |
47 | 48 |
49 |
50 | {notifications.map((notification) => ( 51 | 56 | ))} 57 | {notifications.length === 0 && ( 58 |

63 | No notifications 64 |

65 | )} 66 |
67 |
68 | 69 |
70 |
71 | ); 72 | } 73 | 74 | function Notification({ 75 | notification, 76 | setNotifications, 77 | }: { 78 | notification: INotification; 79 | setNotifications: Dispatch>; 80 | }) { 81 | const navigate = useNavigate(); 82 | 83 | function markNotificationAsRead(notificationId: number) { 84 | request({ 85 | endpoint: `/api/v1/notifications/${notificationId}`, 86 | method: "PUT", 87 | onSuccess: () => { 88 | setNotifications((prev) => 89 | prev.map((notification) => 90 | notification.id === notificationId ? { ...notification, isRead: true } : notification 91 | ) 92 | ); 93 | }, 94 | onFailure: (error) => console.log(error), 95 | }); 96 | } 97 | return ( 98 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /frontend/src/features/feed/pages/Feed/Feed.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { Button } from "../../../../components/Button/Button.tsx"; 4 | import { Loader } from "../../../../components/Loader/Loader.tsx"; 5 | import { usePageTitle } from "../../../../hooks/usePageTitle.tsx"; 6 | import { request } from "../../../../utils/api.ts"; 7 | import { useAuthentication } from "../../../authentication/contexts/AuthenticationContextProvider.tsx"; 8 | import { useWebSocket } from "../../../ws/WebSocketContextProvider.tsx"; 9 | import { LeftSidebar } from "../../components/LeftSidebar/LeftSidebar.tsx"; 10 | import { Madal } from "../../components/Modal/Modal.tsx"; 11 | import { IPost, Post } from "../../components/Post/Post.tsx"; 12 | import { RightSidebar } from "../../components/RightSidebar/RightSidebar.tsx"; 13 | import classes from "./Feed.module.scss"; 14 | 15 | export function Feed() { 16 | usePageTitle("Feed"); 17 | const [showPostingModal, setShowPostingModal] = useState(false); 18 | const [loading, setLoading] = useState(true); 19 | const { user } = useAuthentication(); 20 | const navigate = useNavigate(); 21 | const [posts, setPosts] = useState([]); 22 | const [error, setError] = useState(""); 23 | const ws = useWebSocket(); 24 | 25 | useEffect(() => { 26 | const fetchPosts = async () => { 27 | await request({ 28 | endpoint: "/api/v1/feed", 29 | onSuccess: (data) => { 30 | setPosts(data); 31 | setLoading(false); 32 | }, 33 | onFailure: (error) => setError(error), 34 | }); 35 | }; 36 | fetchPosts(); 37 | }, []); 38 | 39 | useEffect(() => { 40 | const subscription = ws?.subscribe(`/topic/feed/${user?.id}/post`, (data) => { 41 | const post = JSON.parse(data.body); 42 | setPosts((posts) => [post, ...posts]); 43 | }); 44 | return () => subscription?.unsubscribe(); 45 | }, [user?.id, ws]); 46 | 47 | const handlePost = async (data: FormData) => { 48 | await request({ 49 | endpoint: "/api/v1/feed/posts", 50 | method: "POST", 51 | contentType: "multipart/form-data", 52 | body: data, 53 | onSuccess: (data) => setPosts([data, ...posts]), 54 | onFailure: (error) => setError(error), 55 | }); 56 | }; 57 | 58 | return ( 59 |
60 |
61 | 62 |
63 |
64 |
65 | 80 | 83 | 89 |
90 | {error &&
{error}
} 91 | {loading ? ( 92 | 93 | ) : ( 94 |
95 | {posts.map((post) => ( 96 | 97 | ))} 98 | {posts.length === 0 && ( 99 |

Start connecting with poople to build a feed that matters to you.

100 | )} 101 |
102 | )} 103 |
104 |
105 | 106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /frontend/public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom"; 4 | import { ApplicationLayout } from "./components/ApplicationLayout/ApplicationLayout"; 5 | import { AuthenticationLayout } from "./features/authentication/components/AuthenticationLayout/AuthenticationLayout"; 6 | import { AuthenticationContextProvider } from "./features/authentication/contexts/AuthenticationContextProvider"; 7 | import { Login } from "./features/authentication/pages/Login/Login"; 8 | import { Profile as LoginProfile } from "./features/authentication/pages/Profile/Profile"; 9 | import { ResetPassword } from "./features/authentication/pages/ResetPassword/ResetPassword"; 10 | import { Signup } from "./features/authentication/pages/Signup/Signup"; 11 | import { VerifyEmail } from "./features/authentication/pages/VerifyEmail/VerifyEmail"; 12 | import { Feed } from "./features/feed/pages/Feed/Feed"; 13 | import { Notifications } from "./features/feed/pages/Notifications/Notifications"; 14 | import { PostPage } from "./features/feed/pages/Post/Post"; 15 | import { Conversation } from "./features/messaging/pages/Conversation/Conversation"; 16 | import { Messaging } from "./features/messaging/pages/Messages/Messaging"; 17 | import { Connections } from "./features/networking/pages/Connections/Connections"; 18 | import { Invitations } from "./features/networking/pages/Invitations/Invitations"; 19 | import { Network } from "./features/networking/pages/Network/Network"; 20 | import { Posts } from "./features/profile/pages/Posts/Posts"; 21 | import { Profile } from "./features/profile/pages/Profile/Profile"; 22 | import "./index.scss"; 23 | 24 | const router = createBrowserRouter([ 25 | { 26 | element: , 27 | children: [ 28 | { 29 | path: "/", 30 | element: , 31 | children: [ 32 | { 33 | index: true, 34 | element: , 35 | }, 36 | { 37 | path: "posts/:id", 38 | element: , 39 | }, 40 | { 41 | path: "network", 42 | element: , 43 | children: [ 44 | { 45 | index: true, 46 | element: , 47 | }, 48 | { 49 | path: "invitations", 50 | element: , 51 | }, 52 | { 53 | path: "connections", 54 | element: , 55 | }, 56 | ], 57 | }, 58 | { 59 | path: "messaging", 60 | element: , 61 | children: [ 62 | { 63 | path: "conversations/:id", 64 | element: , 65 | }, 66 | ], 67 | }, 68 | { 69 | path: "notifications", 70 | element: , 71 | }, 72 | { 73 | path: "profile/:id", 74 | element: , 75 | }, 76 | { 77 | path: "profile/:id/posts", 78 | element: , 79 | }, 80 | ], 81 | }, 82 | { 83 | path: "/authentication", 84 | element: , 85 | children: [ 86 | { 87 | path: "login", 88 | element: , 89 | }, 90 | { 91 | path: "signup", 92 | element: , 93 | }, 94 | { 95 | path: "request-password-reset", 96 | element: , 97 | }, 98 | { 99 | path: "verify-email", 100 | element: , 101 | }, 102 | { 103 | path: "profile/:id", 104 | element: , 105 | }, 106 | ], 107 | }, 108 | { 109 | path: "*", 110 | element: , 111 | }, 112 | ], 113 | }, 114 | ]); 115 | 116 | createRoot(document.getElementById("root")!).render( 117 | 118 | 119 | 120 | ); 121 | -------------------------------------------------------------------------------- /frontend/src/features/authentication/pages/ResetPassword/ResetPassword.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../../../components/Button/Button"; 2 | import { Input } from "../../../../components/Input/Input"; 3 | import { usePageTitle } from "../../../../hooks/usePageTitle"; 4 | import { request } from "../../../../utils/api"; 5 | import { Box } from "../../components/Box/Box"; 6 | import classes from "./ResetPassword.module.scss"; 7 | 8 | import { useState } from "react"; 9 | import { useNavigate } from "react-router-dom"; 10 | 11 | export function ResetPassword() { 12 | const [emailSent, setEmailSent] = useState(false); 13 | const [email, setEmail] = useState(""); 14 | usePageTitle("Reset Password"); 15 | const [errorMessage, setErrorMessage] = useState(""); 16 | const [isLoading, setIsLoading] = useState(false); 17 | const sendPasswordResetToken = async (email: string) => { 18 | await request({ 19 | endpoint: `/api/v1/authentication/send-password-reset-token?email=${email}`, 20 | method: "PUT", 21 | onSuccess: () => { 22 | setErrorMessage(""); 23 | setEmailSent(true); 24 | }, 25 | onFailure: (error) => { 26 | setErrorMessage(error); 27 | }, 28 | }); 29 | setIsLoading(false); 30 | }; 31 | const navigate = useNavigate(); 32 | 33 | const resetPassword = async (email: string, code: string, password: string) => { 34 | await request({ 35 | endpoint: `/api/v1/authentication/reset-password?email=${email}&token=${code}&newPassword=${password}`, 36 | method: "PUT", 37 | onSuccess: () => { 38 | setErrorMessage(""); 39 | navigate("/login"); 40 | }, 41 | onFailure: (error) => { 42 | setErrorMessage(error); 43 | }, 44 | }); 45 | setIsLoading(false); 46 | }; 47 | return ( 48 |
49 | 50 |

Reset Password

51 | 52 | {!emailSent ? ( 53 |
{ 55 | e.preventDefault(); 56 | setIsLoading(true); 57 | const email = e.currentTarget.email.value; 58 | await sendPasswordResetToken(email); 59 | setEmail(email); 60 | setIsLoading(false); 61 | }} 62 | > 63 |

64 | Enter your email and we’ll send a verification code if it matches an existing LinkedIn 65 | account. 66 |

67 | 68 |

{errorMessage}

69 | 72 | 81 |
82 | ) : ( 83 |
{ 85 | e.preventDefault(); 86 | setIsLoading(true); 87 | const code = e.currentTarget.code.value; 88 | const password = e.currentTarget.password.value; 89 | await resetPassword(email, code, password); 90 | setIsLoading(false); 91 | }} 92 | > 93 |

Enter the verification code we sent to your email and your new password.

94 | 95 | 102 |

{errorMessage}

103 | 106 | 117 |
118 | )} 119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /frontend/src/features/feed/components/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, Dispatch, FormEvent, SetStateAction, useRef, useState } from "react"; 2 | import { Button } from "../../../../components/Button/Button"; 3 | import { Input } from "../../../../components/Input/Input"; 4 | import classes from "./Modal.module.scss"; 5 | interface IPostingMadalProps { 6 | showModal: boolean; 7 | content?: string; 8 | picture?: string; 9 | setShowModal: Dispatch>; 10 | onSubmit: (data: FormData) => Promise; 11 | title: string; 12 | } 13 | export function Madal({ 14 | setShowModal, 15 | showModal, 16 | onSubmit, 17 | content, 18 | picture, 19 | title, 20 | }: IPostingMadalProps) { 21 | const [error, setError] = useState(""); 22 | const [isLoading, setIsLoading] = useState(false); 23 | const [preview, setPreview] = useState(picture); 24 | const textareaRef = useRef(null); 25 | const [file, setFile] = useState(); 26 | 27 | if (!showModal) return null; 28 | 29 | function handleImageChange(e: ChangeEvent) { 30 | setError(""); 31 | const file = e.target.files?.[0]; 32 | 33 | if (!file) return; 34 | 35 | if (!file.type.startsWith("image/")) { 36 | setError("Please select an image file"); 37 | return; 38 | } 39 | setFile(file); 40 | const reader = new FileReader(); 41 | reader.onloadend = () => { 42 | setPreview(reader.result as string); 43 | }; 44 | 45 | reader.readAsDataURL(file); 46 | } 47 | 48 | const handleSubmit = async (e: FormEvent) => { 49 | e.preventDefault(); 50 | setIsLoading(true); 51 | const content = e.currentTarget.content.value; 52 | const formData = new FormData(); 53 | 54 | if (file) { 55 | formData.append("picture", file); 56 | } 57 | 58 | if (!content) { 59 | setError("Content is required"); 60 | setIsLoading(false); 61 | return; 62 | } 63 | 64 | formData.append("content", content); 65 | 66 | try { 67 | await onSubmit(formData); 68 | setPreview(undefined); 69 | setShowModal(false); 70 | } catch (error) { 71 | if (error instanceof Error) { 72 | setError(error.message); 73 | } else { 74 | setError("An error occurred. Please try again later."); 75 | } 76 | } finally { 77 | setIsLoading(false); 78 | } 79 | }; 80 | 81 | return ( 82 |
83 |
84 |
85 |

{title}

86 | 89 |
90 |
91 |
92 |