├── 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 |
15 | {children}
16 |
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 |
18 |
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 |
14 | {label}
15 |
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 |
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 | {
35 | navigate("conversations/new");
36 | }}
37 | className={classes.new}
38 | >
39 | +
40 |
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 | {
45 | setSuggestions([]);
46 | setSearchTerm("");
47 | navigate(`/profile/${user.id}`);
48 | }}
49 | >
50 |
51 |
52 |
53 | {user.firstName} {user.lastName}
54 |
55 |
56 | {user.position} at {user.company}
57 |
58 |
59 |
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 | navigate(`/messaging/conversations/${conversation.id}`)}
54 | >
55 |
60 |
61 | {unreadMessagesCount > 0 && {unreadMessagesCount}
}
62 |
63 |
64 |
65 | {conversationUserToDisplay.firstName} {conversationUserToDisplay.lastName}
66 |
67 |
68 | {conversation.messages[conversation.messages.length - 1]?.content}
69 |
70 |
71 |
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 |
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 | setSent(false)}>
63 | Received
64 |
65 | setSent(true)}>
66 | Sent
67 |
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 |
navigate("/profile/" + suggestion.id)}
39 | >
40 |
41 |
42 |
43 |
navigate("/profile/" + suggestion.id)}>
44 |
45 | {suggestion.firstName} {suggestion.lastName}
46 |
47 |
48 | {suggestion.position} at {suggestion.company}
49 |
50 |
51 |
{
56 | request({
57 | endpoint: "/api/v1/networking/connections?recipientId=" + suggestion.id,
58 | method: "POST",
59 | onSuccess: () => {
60 | setSuggestions(suggestions.filter((s) => s.id !== suggestion.id));
61 | },
62 | onFailure: (error) => console.log(error),
63 | });
64 | }}
65 | >
66 | + Connect
67 |
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 |
59 |
60 |
navigate("/profile/" + user?.id)}>
61 |
69 |
70 |
{user?.firstName + " " + user?.lastName}
71 |
{user?.position + " at " + user?.company}
72 |
73 | {/*
74 |
Profile viewers
75 |
0
76 |
*/}
77 |
navigate("/network/connections")}>
78 | Connexions
79 |
80 | {connections.filter((connection) => connection.status === "ACCEPTED").length}
81 |
82 |
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 |
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 | setEditingPicture(false)}>X
33 |
34 | {type === "profile" ? (
35 |
36 |
37 |
38 | ) : (
39 |
40 |
41 |
42 | )}
43 |
50 |
51 |
{
53 | setNewPicturePreview(null);
54 | setNewPicture(null);
55 | }}
56 | >
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
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 |
67 | Or
68 |
69 | {oauthError &&
{oauthError}
}
70 |
{
73 | startOauth();
74 | }}
75 | >
76 |
77 |
78 |
79 | Continue with Google
80 |
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 |
setEditingAbout(!editingAbout)}>
39 |
40 |
41 |
42 |
43 | ) : (
44 |
45 |
{
47 | setEditingAbout(false);
48 | setAboutInput(user?.about || "");
49 | }}
50 | >
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
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 |
67 | Or
68 | {oauthError && {oauthError}
}
69 | {
72 | startOauth();
73 | }}
74 | >
75 |
76 |
77 |
78 | Continue with Google
79 |
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 |
{
38 | setShowProfileMenu((prev) => !prev);
39 | if (window.innerWidth <= 1080) {
40 | setShowNavigationMenu(false);
41 | }
42 | }}
43 | >
44 |
53 |
54 |
{user?.firstName + " " + user?.lastName?.charAt(0) + "."}
55 |
56 |
57 |
58 | {showProfileMenu ? (
59 |
60 |
61 |
70 |
71 |
{user?.firstName + " " + user?.lastName}
72 |
{user?.position + " at " + user?.company}
73 |
74 |
75 |
76 | {
81 | setShowProfileMenu(false);
82 | navigate("/profile/" + user?.id);
83 | }}
84 | >
85 | View Profile
86 |
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 |
{
39 | navigate(`/profile/${comment.author.id}`);
40 | }}
41 | className={classes.author}
42 | >
43 |
48 |
49 |
50 | {comment.author.firstName + " " + comment.author.lastName}
51 |
52 |
53 | {comment.author.position + " at " + comment.author.company}
54 |
55 |
56 |
57 |
58 | {comment.author.id == user?.id && (
59 |
setShowActions(!showActions)}
62 | >
63 |
64 |
65 |
66 |
67 | )}
68 |
69 | {showActions && (
70 |
71 | setEditing(true)}>Edit
72 | deleteComment(comment.id)}>Delete
73 |
74 | )}
75 |
76 |
{comment.content}
77 | >
78 | ) : (
79 |
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 | {
100 | markNotificationAsRead(notification.id);
101 | navigate(`/posts/${notification.resourceId}`);
102 | }}
103 | className={
104 | notification.read ? classes.notification : `${classes.notification} ${classes.unread}`
105 | }
106 | >
107 |
112 |
113 |
118 | {notification.actor.firstName + " " + notification.actor.lastName} {" "}
119 | {notification.type === NotificationType.LIKE ? "liked" : "commented on"} your post.
120 |
121 |
122 |
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 |
{
67 | navigate(`/profile/${user?.id}`);
68 | }}
69 | >
70 |
79 |
80 |
setShowPostingModal(true)}>
81 | Start a post
82 |
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 |
2 |
3 |
--------------------------------------------------------------------------------
/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 |
82 | ) : (
83 |
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 | setShowModal(false)}>
87 | X
88 |
89 |
90 |
134 |
135 |
136 | );
137 | }
138 |
--------------------------------------------------------------------------------