├── public ├── _redirects └── index.html ├── src ├── react-app-env.d.ts ├── mobile │ ├── pages │ │ ├── video │ │ │ ├── video.scss │ │ │ └── index.tsx │ │ ├── following │ │ │ └── index.tsx │ │ ├── profile │ │ │ ├── OwnProfile.tsx │ │ │ └── profile.scss │ │ ├── notifications │ │ │ ├── notifications.scss │ │ │ └── index.tsx │ │ ├── home │ │ │ ├── home.scss │ │ │ └── index.tsx │ │ ├── search │ │ │ ├── search.scss │ │ │ └── index.tsx │ │ ├── edit-profile │ │ │ └── edit-profile.scss │ │ └── upload │ │ │ └── upload.scss │ ├── components │ │ ├── swiper │ │ │ ├── swiper.module.scss │ │ │ └── index.tsx │ │ ├── page-with-navbar │ │ │ ├── page-with-navbar.module.scss │ │ │ └── index.tsx │ │ ├── profile-video │ │ │ ├── profile-video.module.scss │ │ │ └── index.tsx │ │ ├── search-bar │ │ │ ├── search-bar.module.scss │ │ │ └── index.tsx │ │ ├── drawer │ │ │ ├── AccountBox.tsx │ │ │ ├── drawer.scss │ │ │ └── index.tsx │ │ ├── searched-account │ │ │ ├── searched-account.module.scss │ │ │ └── index.tsx │ │ ├── searched-video │ │ │ ├── searched-video.module.scss │ │ │ └── index.tsx │ │ ├── navbar │ │ │ ├── navbar.scss │ │ │ └── index.tsx │ │ ├── unauthed-page │ │ │ ├── unauthed-page.scss │ │ │ └── index.tsx │ │ ├── notification-box │ │ │ ├── notification-box.module.scss │ │ │ └── index.tsx │ │ └── comments-modal │ │ │ ├── AddComment.tsx │ │ │ └── Reply.tsx │ ├── store │ │ ├── index.ts │ │ └── slices │ │ │ └── navbar-slice.ts │ ├── helpers │ │ └── error-notification.ts │ ├── index.scss │ └── index.tsx ├── common │ ├── components │ │ ├── legal-notice │ │ │ ├── legal-notice.scss │ │ │ └── index.tsx │ │ ├── fullscreen-spinner │ │ │ ├── fullscreen-spinner.module.scss │ │ │ └── index.tsx │ │ ├── dropdown │ │ │ ├── dropdown.module.scss │ │ │ └── index.tsx │ │ ├── loading-spinner │ │ │ ├── index.tsx │ │ │ └── spinner.module.scss │ │ ├── private-route │ │ │ └── index.tsx │ │ ├── notification │ │ │ ├── notification.module.scss │ │ │ └── index.tsx │ │ ├── alert │ │ │ ├── alert.module.scss │ │ │ └── index.tsx │ │ ├── input-field │ │ │ ├── input.module.scss │ │ │ └── index.tsx │ │ └── auth-modal │ │ │ ├── auth-modal.scss │ │ │ ├── index.tsx │ │ │ ├── LogIn.tsx │ │ │ └── SignUp.tsx │ ├── api │ │ ├── auth.ts │ │ ├── index.ts │ │ ├── feed.ts │ │ ├── user.ts │ │ └── video.ts │ ├── store │ │ ├── slices │ │ │ ├── auth-modal-slice.ts │ │ │ ├── notification-slice.ts │ │ │ └── auth-slice.ts │ │ └── index.ts │ ├── constants.ts │ ├── utils.ts │ ├── types.ts │ └── styles.scss ├── pc │ ├── components │ │ ├── container │ │ │ ├── container.module.scss │ │ │ └── index.tsx │ │ ├── page-with-sidebar │ │ │ └── index.tsx │ │ ├── profile-card │ │ │ ├── profile-card.scss │ │ │ └── index.tsx │ │ ├── play-on-scroll │ │ │ └── index.ts │ │ ├── profile-buttons │ │ │ ├── profile-buttons.scss │ │ │ └── index.tsx │ │ ├── action-button │ │ │ ├── action-button.module.scss │ │ │ └── index.tsx │ │ ├── search-results │ │ │ ├── AccountCard.tsx │ │ │ ├── VideoCard.tsx │ │ │ └── search-results.module.scss │ │ ├── suggestion-card │ │ │ ├── suggestion-card.module.scss │ │ │ └── index.tsx │ │ ├── video-card │ │ │ ├── useVideoDynamics.ts │ │ │ └── video-card.scss │ │ ├── user-dropdown │ │ │ ├── user-dropdown.scss │ │ │ ├── index.tsx │ │ │ └── DD.tsx │ │ ├── video-modal │ │ │ ├── Likes.tsx │ │ │ ├── CommentForm.tsx │ │ │ ├── ReplyForm.tsx │ │ │ └── LoadVideoModal.tsx │ │ ├── header │ │ │ ├── SearchBar.tsx │ │ │ └── header.module.scss │ │ ├── follow-button │ │ │ └── index.tsx │ │ ├── sidebar │ │ │ └── sidebar.scss │ │ └── video-tag │ │ │ └── video-tag.module.scss │ ├── pages │ │ ├── video │ │ │ ├── video.scss │ │ │ └── index.tsx │ │ ├── home │ │ │ ├── home.scss │ │ │ └── index.tsx │ │ ├── profile │ │ │ ├── VideosLayout.tsx │ │ │ └── profile.scss │ │ ├── following │ │ │ ├── following.scss │ │ │ └── index.tsx │ │ ├── search │ │ │ ├── search.scss │ │ │ └── index.tsx │ │ ├── edit-profile │ │ │ └── edit-profile.scss │ │ └── upload │ │ │ └── upload-page.scss │ ├── store │ │ ├── index.ts │ │ └── slices │ │ │ ├── search-slice.ts │ │ │ └── sidebar-slice.ts │ ├── index.tsx │ └── index.scss ├── index.tsx └── App.tsx ├── gallery ├── video.png ├── homepage.png ├── notice.png ├── mobile_home.png ├── tiktok+react.png ├── video_light.png ├── homepage_light.png ├── mobile_profile.png ├── mobile_home_light.png └── mobile_profile_light.png ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── package.json /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /gallery/video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/video.png -------------------------------------------------------------------------------- /gallery/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/homepage.png -------------------------------------------------------------------------------- /gallery/notice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/notice.png -------------------------------------------------------------------------------- /src/mobile/pages/video/video.scss: -------------------------------------------------------------------------------- 1 | .video-page { 2 | background: var(--clr-background); 3 | } 4 | -------------------------------------------------------------------------------- /gallery/mobile_home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/mobile_home.png -------------------------------------------------------------------------------- /gallery/tiktok+react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/tiktok+react.png -------------------------------------------------------------------------------- /gallery/video_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/video_light.png -------------------------------------------------------------------------------- /gallery/homepage_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/homepage_light.png -------------------------------------------------------------------------------- /gallery/mobile_profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/mobile_profile.png -------------------------------------------------------------------------------- /gallery/mobile_home_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/mobile_home_light.png -------------------------------------------------------------------------------- /gallery/mobile_profile_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft-coded/tiktok/HEAD/gallery/mobile_profile_light.png -------------------------------------------------------------------------------- /src/mobile/components/swiper/swiper.module.scss: -------------------------------------------------------------------------------- 1 | .swiper-container, 2 | .swiper-slide { 3 | width: 100%; 4 | height: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /src/mobile/pages/following/index.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from "../home"; 2 | 3 | export default function Following() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/components/legal-notice/legal-notice.scss: -------------------------------------------------------------------------------- 1 | .legal-notice { 2 | a { 3 | color: var(--clr-primary); 4 | text-decoration: underline; 5 | margin: 0 4px; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "semi": true, 4 | "arrowParens": "avoid", 5 | "tabWidth": 2, 6 | "singleQuote": false, 7 | "printWidth": 80, 8 | "useTabs": true 9 | } -------------------------------------------------------------------------------- /src/mobile/store/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "@reduxjs/toolkit"; 2 | 3 | import navbarReducer from "./slices/navbar-slice"; 4 | 5 | export default combineReducers({ navbar: navbarReducer }); 6 | -------------------------------------------------------------------------------- /src/mobile/components/page-with-navbar/page-with-navbar.module.scss: -------------------------------------------------------------------------------- 1 | .page { 2 | height: 100%; 3 | width: 100%; 4 | } 5 | 6 | .container { 7 | height: calc(100% - var(--navbar-height)); 8 | width: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /src/pc/components/container/container.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../index.scss"; 2 | 3 | .container { 4 | max-width: $viewport-max-width; 5 | margin: auto; 6 | padding: 0 20px; 7 | background: var(--clr-background); 8 | } 9 | -------------------------------------------------------------------------------- /src/pc/pages/video/video.scss: -------------------------------------------------------------------------------- 1 | $container-width: 692px; 2 | 3 | .video-page-container { 4 | display: flex; 5 | justify-content: space-between; 6 | 7 | .content-container { 8 | width: $container-width; 9 | height: 100%; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/common/components/fullscreen-spinner/fullscreen-spinner.module.scss: -------------------------------------------------------------------------------- 1 | .fsspinner { 2 | position: fixed; 3 | inset: 0; 4 | z-index: 15; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | background: rgba(0, 0, 0, 0.5); 9 | } 10 | -------------------------------------------------------------------------------- /src/pc/store/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "@reduxjs/toolkit"; 2 | 3 | import sidebarReducer from "./slices/sidebar-slice"; 4 | import searchReducer from "./slices/search-slice"; 5 | 6 | export default combineReducers({ 7 | sidebar: sidebarReducer, 8 | search: searchReducer 9 | }); 10 | -------------------------------------------------------------------------------- /src/pc/pages/home/home.scss: -------------------------------------------------------------------------------- 1 | $cards-container-width: 700px; 2 | 3 | .homepage-container { 4 | display: flex; 5 | justify-content: space-between; 6 | gap: 5vw; 7 | min-height: 100%; 8 | } 9 | 10 | .content-container { 11 | display: flex; 12 | flex-direction: column; 13 | width: $cards-container-width; 14 | } 15 | -------------------------------------------------------------------------------- /src/common/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "."; 2 | import { LoginData, SignupData } from "../types"; 3 | 4 | const authRoute = "/auth"; 5 | 6 | export const login = (payload: LoginData) => 7 | apiClient.post(authRoute + "/login", payload); 8 | 9 | export const signup = (payload: SignupData) => 10 | apiClient.post(authRoute + "/signup", payload); 11 | -------------------------------------------------------------------------------- /src/mobile/components/profile-video/profile-video.module.scss: -------------------------------------------------------------------------------- 1 | .video { 2 | position: relative; 3 | height: 165.517px; 4 | 5 | video { 6 | width: 100%; 7 | height: 100%; 8 | background-color: #000; 9 | } 10 | 11 | .spinner { 12 | position: absolute; 13 | inset: 0; 14 | z-index: 5; 15 | --circle-size: 16px; 16 | background-color: rgba(0, 0, 0, 0.4); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/mobile/components/search-bar/search-bar.module.scss: -------------------------------------------------------------------------------- 1 | .search-bar { 2 | display: flex; 3 | align-items: center; 4 | gap: 12px; 5 | padding: 8px 12px; 6 | 7 | .input { 8 | border-radius: 4px; 9 | } 10 | 11 | button { 12 | font-size: 1.4rem; 13 | color: var(--clr-primary); 14 | 15 | &:disabled { 16 | color: rgba(var(--clr-secondary-values), 0.3); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/pc/components/page-with-sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import Container from "../container"; 2 | import Sidebar from "../sidebar"; 3 | 4 | interface Props { 5 | children: React.ReactNode; 6 | className?: string; 7 | } 8 | 9 | export default function PageWithSidebar({ children, className }: Props) { 10 | return ( 11 | 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/pc/pages/profile/VideosLayout.tsx: -------------------------------------------------------------------------------- 1 | import ProfileCard from "../../components/profile-card"; 2 | 3 | interface Props { 4 | videos: string[]; 5 | } 6 | 7 | export default function VideosLayout({ videos }: Props) { 8 | return videos.length === 0 ? ( 9 |

No videos.

10 | ) : ( 11 | <> 12 | {videos.map((video, i) => ( 13 | 14 | ))} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/common/store/slices/auth-modal-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const authModalSlice = createSlice({ 4 | name: "authModal", 5 | initialState: { 6 | show: false 7 | }, 8 | reducers: { 9 | showModal(state) { 10 | state.show = true; 11 | }, 12 | hideModal(state) { 13 | state.show = false; 14 | } 15 | } 16 | }); 17 | 18 | export default authModalSlice.reducer; 19 | export const authModalActions = authModalSlice.actions; 20 | -------------------------------------------------------------------------------- /src/pc/components/profile-card/profile-card.scss: -------------------------------------------------------------------------------- 1 | $card-width: 196px; 2 | $card-height: 260px; 3 | 4 | .profile-card { 5 | width: $card-width; 6 | height: $card-height; 7 | 8 | .video-container { 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | width: 100%; 13 | height: 100%; 14 | background: #000; 15 | overflow: hidden; 16 | 17 | video { 18 | background: #000; 19 | width: 100%; 20 | height: 100%; 21 | cursor: pointer; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider as ReduxProvider } from "react-redux"; 4 | import { BrowserRouter } from "react-router-dom"; 5 | 6 | import App from "./App"; 7 | import store from "./common/store"; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById("root") 18 | ); 19 | -------------------------------------------------------------------------------- /src/common/components/fullscreen-spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import classes from "./fullscreen-spinner.module.scss"; 2 | import LoadingSpinner from "../loading-spinner"; 3 | import { joinClasses } from "../../utils"; 4 | import { ComponentProps } from "../../types"; 5 | 6 | export default function FullscreenSpinner({ className }: ComponentProps) { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/mobile/pages/profile/OwnProfile.tsx: -------------------------------------------------------------------------------- 1 | import Profile from "."; 2 | import { useAppSelector } from "../../../common/store"; 3 | import UnauthedPage from "../../components/unauthed-page"; 4 | 5 | export default function OwnProfile() { 6 | const { isAuthenticated: isAuthed } = useAppSelector(state => state.auth); 7 | 8 | return isAuthed ? ( 9 | 10 | ) : ( 11 | } 15 | /> 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/pc/pages/following/following.scss: -------------------------------------------------------------------------------- 1 | $container-width: 700px; 2 | 3 | .following-container { 4 | display: flex; 5 | justify-content: space-between; 6 | gap: 5vw; 7 | min-height: 100%; 8 | } 9 | 10 | .suggested-container { 11 | display: grid; 12 | grid-template-columns: repeat(3, 1fr); 13 | gap: 18px; 14 | width: $container-width; 15 | margin-top: 28px; 16 | 17 | &.ungrid { 18 | display: block; 19 | } 20 | } 21 | 22 | .cards-container { 23 | display: flex; 24 | flex-direction: column; 25 | width: $container-width; 26 | } 27 | -------------------------------------------------------------------------------- /src/mobile/components/drawer/AccountBox.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import "./drawer.scss"; 4 | import { UserData } from "../../../common/types"; 5 | import constants from "../../../common/constants"; 6 | 7 | export default function AccountBox(props: UserData) { 8 | return ( 9 | 10 |
11 | {props.name} 12 |
13 |

{props.username}

14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/pc/store/slices/search-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | interface InitState { 4 | query: string; 5 | } 6 | 7 | const initialState: InitState = { 8 | query: "" 9 | }; 10 | 11 | const searchSlice = createSlice({ 12 | name: "search", 13 | initialState, 14 | reducers: { 15 | putQuery(state, action) { 16 | state.query = action.payload; 17 | }, 18 | dropQuery(state) { 19 | state.query = ""; 20 | } 21 | } 22 | }); 23 | 24 | export default searchSlice.reducer; 25 | export const searchActions = searchSlice.actions; 26 | -------------------------------------------------------------------------------- /src/common/api/index.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const baseURL = 4 | process.env.REACT_APP_SERVER_URL || "http://localhost:5000"; 5 | const timeout = 15000; // 15s 6 | 7 | export const apiClient = axios.create({ 8 | baseURL, 9 | timeout 10 | }); 11 | 12 | apiClient.interceptors.response.use(undefined, error => { 13 | let msg: string; 14 | if (error.response) msg = error.response.data.message; 15 | else if (error.request) msg = "Network error. Check your connection"; 16 | else msg = "Something went wrong. Try again"; 17 | 18 | error.message = msg; 19 | return Promise.reject(error); 20 | }); 21 | -------------------------------------------------------------------------------- /src/common/api/feed.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "."; 2 | 3 | const feedURL = "/feed"; 4 | 5 | export const getFeed = (username?: string | null, skip?: number) => 6 | apiClient.get(feedURL, { params: { username, skip } }); 7 | 8 | export const getSuggested = (limit?: number) => 9 | apiClient.get(feedURL + "/suggested", { params: { limit } }); 10 | 11 | export const getFollowingVids = (username: string, skip?: number) => 12 | apiClient.get(feedURL + "/following", { params: { username, skip } }); 13 | 14 | export const search = (query: string, send: "accounts" | "videos") => 15 | apiClient.post(feedURL + "/search", { query, send }); 16 | -------------------------------------------------------------------------------- /src/mobile/pages/notifications/notifications.scss: -------------------------------------------------------------------------------- 1 | $header-height: 44px; 2 | 3 | .notifications-page { 4 | overflow: auto; 5 | background-color: var(--clr-background); 6 | color: var(--clr-text); 7 | padding-bottom: 12px; 8 | 9 | header { 10 | height: $header-height; 11 | line-height: $header-height; 12 | font-size: 17px; 13 | font-weight: 600; 14 | padding: 0 18px; 15 | text-align: center; 16 | border-bottom: 2px solid var(--clr-border); 17 | } 18 | 19 | .content { 20 | padding-top: 8px; 21 | } 22 | 23 | .no-notifs { 24 | text-align: center; 25 | color: rgba(var(--clr-secondary-values), 0.5); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/mobile/helpers/error-notification.ts: -------------------------------------------------------------------------------- 1 | import { notificationActions } from "../../common/store/slices/notification-slice"; 2 | import { useAppDispatch } from "../../common/store"; 3 | 4 | export async function errorNotification( 5 | fn: () => any, 6 | dispatch: ReturnType, 7 | errFn?: (() => any) | null, 8 | errMessage?: string | null 9 | ) { 10 | try { 11 | await fn(); 12 | } catch (err: any) { 13 | dispatch( 14 | notificationActions.showNotification({ 15 | type: "error", 16 | message: errMessage ? errMessage + " " + err.message : err.message 17 | }) 18 | ); 19 | errFn?.(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/pc/components/play-on-scroll/index.ts: -------------------------------------------------------------------------------- 1 | function cb(entries: IntersectionObserverEntry[]) { 2 | entries.forEach(entry => { 3 | const vid = entry.target.querySelector("video")!; 4 | if (entry.isIntersecting) vid.play(); 5 | else if (!vid.paused) vid.pause(); 6 | }); 7 | } 8 | 9 | const observer = new IntersectionObserver(cb, { threshold: 0.8 }); 10 | 11 | export default function playOnScroll(cardClassName: string) { 12 | const cards = document.querySelectorAll("." + cardClassName); 13 | cards.forEach(card => observer.observe(card)); 14 | 15 | return () => { 16 | cards.forEach(card => observer.unobserve(card)); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/pc/components/container/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, createElement } from "react"; 2 | 3 | import classes from "./container.module.scss"; 4 | import { joinClasses } from "../../../common/utils"; 5 | import { ComponentProps } from "../../../common/types"; 6 | 7 | interface ContainerProps extends ComponentProps { 8 | children: ReactNode; 9 | component?: string; 10 | } 11 | 12 | export default function Container({ 13 | children, 14 | component, 15 | className 16 | }: ContainerProps) { 17 | return createElement( 18 | component ? component : "div", 19 | { 20 | className: joinClasses(classes.container, className) 21 | }, 22 | children 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | import { baseURL } from "./api"; 2 | 3 | // eslint-disable-next-line 4 | export default { 5 | usernameRegex: /^[a-zA-Z0-9_]+$/, // letters, numbers and underscore 6 | usernameMinLen: 4, 7 | usernameMaxLen: 15, 8 | passwordMinLen: 6, 9 | nameMaxLen: 30, 10 | descriptionMaxLen: 300, 11 | videoSizeLimit: 41943040, // 40MB 12 | pfpRegex: /jpg|jpeg|png/i, 13 | pfpSizeLimit: 2097152, // 2MB 14 | musicMaxLen: 30, 15 | captionMaxLen: 150, 16 | tagsMaxLen: 200, 17 | commentMaxLen: 300, 18 | searchQueryMaxLen: 25, 19 | pfpLink: baseURL + "/user/profilePhoto", 20 | videoLink: baseURL + "/video/stream", 21 | mobileWidth: 600 // any screen <= 600px will be considered mobile 22 | }; 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/mobile/components/searched-account/searched-account.module.scss: -------------------------------------------------------------------------------- 1 | $pfp-size: 56px; 2 | 3 | .account { 4 | display: flex; 5 | gap: 12px; 6 | padding: 0 8px; 7 | 8 | .pfp { 9 | width: $pfp-size; 10 | height: $pfp-size; 11 | } 12 | 13 | .content { 14 | display: flex; 15 | flex-direction: column; 16 | gap: 4px; 17 | font-size: 0.9rem; 18 | line-height: 1rem; 19 | color: rgba(var(--clr-secondary-values), 0.7); 20 | 21 | h4 { 22 | font-weight: 600; 23 | color: var(--clr-text); 24 | } 25 | 26 | h5 { 27 | font-size: 0.83rem; 28 | font-weight: 500; 29 | 30 | span { 31 | font-weight: 400; 32 | } 33 | } 34 | 35 | p { 36 | font-size: 0.8rem; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/common/components/dropdown/dropdown.module.scss: -------------------------------------------------------------------------------- 1 | $dropdown-width: 223px; 2 | $animation-time: 0.1s ease-in-out; 3 | 4 | .dropdown { 5 | position: absolute; 6 | bottom: 0; 7 | display: flex; 8 | flex-direction: column; 9 | width: $dropdown-width; 10 | padding: 8px 0; 11 | background: var(--clr-bg-elevation-2, var(--clr-background)); 12 | box-shadow: var(--shadows, 0 0 15px 5px var(--clr-shadow)); 13 | border-radius: 8px; 14 | font-size: 0.9rem; 15 | font-weight: 500; 16 | transition: opacity $animation-time; 17 | animation: dropdown-anim $animation-time; 18 | 19 | &.hide { 20 | opacity: 0; 21 | } 22 | } 23 | @keyframes dropdown-anim { 24 | from { 25 | opacity: 0; 26 | } 27 | to { 28 | opacity: 1; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/mobile/components/page-with-navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import classes from "./page-with-navbar.module.scss"; 4 | import { joinClasses } from "../../../common/utils"; 5 | import Navbar from "../navbar"; 6 | 7 | interface Props { 8 | children: ReactNode; 9 | wrapperClassName?: string; 10 | containerClassName?: string; 11 | } 12 | 13 | export default function PageWithNavbar({ 14 | children, 15 | wrapperClassName, 16 | containerClassName 17 | }: Props) { 18 | return ( 19 |
20 |
21 | {children} 22 |
23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/common/components/loading-spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import classes from "./spinner.module.scss"; 2 | import { joinClasses } from "../../utils"; 3 | import { ComponentProps } from "../../types"; 4 | 5 | export default function LoadingSpinner({ className }: ComponentProps) { 6 | return ( 7 |
8 |
9 | 15 | 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/common/components/private-route/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Navigate, Outlet } from "react-router-dom"; 3 | 4 | import { useAppSelector, useAppDispatch } from "../../store"; 5 | import { notificationActions } from "../../store/slices/notification-slice"; 6 | 7 | export default function PrivateRoute() { 8 | const isAuthed = useAppSelector(state => state.auth.isAuthenticated); 9 | const dispatch = useAppDispatch(); 10 | 11 | useEffect(() => { 12 | if (isAuthed) return; 13 | dispatch( 14 | notificationActions.showNotification({ 15 | type: "error", 16 | message: "Log in to continue" 17 | }) 18 | ); 19 | }, [dispatch, isAuthed]); 20 | 21 | return isAuthed ? : ; 22 | } 23 | -------------------------------------------------------------------------------- /src/pc/components/profile-buttons/profile-buttons.scss: -------------------------------------------------------------------------------- 1 | .profile-category-buttons { 2 | margin-top: 12px; 3 | 4 | .btns { 5 | display: flex; 6 | 7 | button { 8 | width: 50%; 9 | font-weight: 600; 10 | font-size: 1.1rem; 11 | color: rgba(var(--clr-secondary-values), 0.5); 12 | 13 | &.active { 14 | color: inherit; 15 | } 16 | } 17 | } 18 | 19 | .button-underbar { 20 | margin-top: 8px; 21 | height: 1px; 22 | width: 100%; 23 | background-color: rgba(var(--clr-secondary-values), 0.3); 24 | position: relative; 25 | 26 | span { 27 | height: 2px; 28 | background-color: var(--clr-secondary); 29 | width: 50%; 30 | position: absolute; 31 | top: -50%; 32 | transition: left 0.25s ease-out; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/pc/components/action-button/action-button.module.scss: -------------------------------------------------------------------------------- 1 | $action-btn-size: 48px; 2 | 3 | .action-btn-container { 4 | display: flex; 5 | align-items: center; 6 | gap: 4px; 7 | 8 | span { 9 | font-size: 0.72rem; 10 | font-weight: 600; 11 | color: rgba(var(--clr-secondary-values), 0.75); 12 | } 13 | } 14 | 15 | .action-btn { 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | width: $action-btn-size; 20 | height: $action-btn-size; 21 | border-radius: 50%; 22 | background-color: rgba(var(--clr-secondary-values), 0.06); 23 | font-size: 1.3rem; 24 | cursor: pointer; 25 | transition: background-color 0.25s ease-out, color 0.25s ease-out; 26 | 27 | &:hover { 28 | background-color: rgba(var(--clr-secondary-values), 0.1); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/pc/components/action-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import classes from "./action-button.module.scss"; 4 | import { joinClasses } from "../../../common/utils"; 5 | import { ComponentProps } from "../../../common/types"; 6 | 7 | interface ABProps extends ComponentProps { 8 | number: string | number; 9 | icon: ReactNode; 10 | onClick?: () => void; 11 | } 12 | 13 | export default function ActionButton({ 14 | number, 15 | icon, 16 | className, 17 | onClick 18 | }: ABProps) { 19 | return ( 20 |
21 |
25 | {icon} 26 |
27 | {number} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/common/store/index.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, TypedUseSelectorHook, useSelector } from "react-redux"; 2 | import { configureStore } from "@reduxjs/toolkit"; 3 | 4 | import authReducer from "./slices/auth-slice"; 5 | import notificationReducer from "./slices/notification-slice"; 6 | import authModalReducer from "./slices/auth-modal-slice"; 7 | import PCReducer from "../../pc/store"; 8 | import MobileReducer from "../../mobile/store"; 9 | 10 | const store = configureStore({ 11 | reducer: { 12 | auth: authReducer, 13 | authModal: authModalReducer, 14 | notification: notificationReducer, 15 | pc: PCReducer, 16 | mobile: MobileReducer 17 | } 18 | }); 19 | 20 | export default store; 21 | export const useAppDispatch = () => useDispatch(); 22 | export const useAppSelector: TypedUseSelectorHook< 23 | ReturnType 24 | > = useSelector; 25 | -------------------------------------------------------------------------------- /src/mobile/components/searched-account/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import classes from "./searched-account.module.scss"; 4 | import { UserData } from "../../../common/types"; 5 | import { joinClasses } from "../../../common/utils"; 6 | import constants from "../../../common/constants"; 7 | 8 | export default function SearchedAccount(props: UserData) { 9 | return ( 10 | 11 |
12 | {props.name} 13 |
14 |
15 |

{props.username}

16 |
17 | {props.name}  18 | 19 | • {props.followers}  20 | {props.followers === 1 ? "Follower" : "Followers"} 21 | 22 |
23 |

{props.description}

24 |
25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/common/store/slices/notification-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | interface InitState { 4 | show: boolean; 5 | type: "error" | "warning" | "success" | "info" | null; 6 | message: string | null; 7 | } 8 | 9 | const initialState: InitState = { 10 | show: false, 11 | type: null, 12 | message: null 13 | }; 14 | 15 | const notifSlice = createSlice({ 16 | name: "notification", 17 | initialState, 18 | reducers: { 19 | showNotification( 20 | state, 21 | action: PayloadAction<{ 22 | type: InitState["type"]; 23 | message: InitState["message"]; 24 | }> 25 | ) { 26 | state.show = true; 27 | state.type = action.payload.type; 28 | state.message = action.payload.message; 29 | }, 30 | hideNotification(state) { 31 | state.show = false; 32 | state.type = null; 33 | state.message = null; 34 | } 35 | } 36 | }); 37 | 38 | export default notifSlice.reducer; 39 | export const notificationActions = notifSlice.actions; 40 | -------------------------------------------------------------------------------- /src/mobile/pages/home/home.scss: -------------------------------------------------------------------------------- 1 | .homepage-container { 2 | background-color: #000; 3 | 4 | header { 5 | display: flex; 6 | align-items: center; 7 | position: fixed; 8 | top: 0; 9 | left: 0; 10 | right: 0; 11 | z-index: 5; 12 | color: rgba(255, 255, 255, 0.5); 13 | text-align: center; 14 | padding: 12px; 15 | pointer-events: none; 16 | font-weight: 500; 17 | 18 | button { 19 | font-size: 1.6rem; 20 | pointer-events: auto; 21 | 22 | i { 23 | color: inherit; 24 | } 25 | } 26 | 27 | nav { 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | gap: 24px; 32 | position: absolute; 33 | left: 50%; 34 | transform: translateX(-50%); 35 | white-space: nowrap; 36 | pointer-events: auto; 37 | 38 | .active { 39 | color: #fff; 40 | } 41 | } 42 | } 43 | 44 | .loader { 45 | width: 100%; 46 | height: 100%; 47 | display: flex; 48 | align-items: center; 49 | justify-content: center; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/pc/components/search-results/AccountCard.tsx: -------------------------------------------------------------------------------- 1 | import classes from "./search-results.module.scss"; 2 | import { Link } from "react-router-dom"; 3 | import { UserData } from "../../../common/types"; 4 | import { joinClasses } from "../../../common/utils"; 5 | import constants from "../../../common/constants"; 6 | 7 | export default function AccountCard(props: UserData) { 8 | return ( 9 | 13 |
14 | {props.name} 15 |
16 |
17 |

{props.username}

18 |
19 | {props.name} | {props.followers} 20 | {props.followers === 1 ? "Follower" : "Followers"} 21 |
22 |

{props.description}

23 |
24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, Suspense, lazy } from "react"; 2 | 3 | import "./common/styles.scss"; 4 | import { authActions } from "./common/store/slices/auth-slice"; 5 | import { useAppDispatch, useAppSelector } from "./common/store"; 6 | import FullscreenSpinner from "./common/components/fullscreen-spinner"; 7 | import constants from "./common/constants"; 8 | const PCLayout = lazy(() => import("./pc")); 9 | const MobileLayout = lazy(() => import("./mobile")); 10 | 11 | export default function App() { 12 | const dispatch = useAppDispatch(); 13 | const authStatus = useAppSelector(state => state.auth.status); 14 | const isMobile = window.matchMedia( 15 | "(max-width: " + constants.mobileWidth + "px)" 16 | ).matches; 17 | 18 | useEffect(() => { 19 | dispatch(authActions.loginOnLoad()); 20 | }, [dispatch]); 21 | 22 | return authStatus === "fetching" ? ( 23 | 24 | ) : ( 25 | }> 26 | {isMobile ? : } 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/mobile/components/searched-video/searched-video.module.scss: -------------------------------------------------------------------------------- 1 | $pfp-size: 24px; 2 | 3 | .video { 4 | overflow: hidden; 5 | 6 | .vid-container { 7 | border-radius: 6px; 8 | overflow: hidden; 9 | width: 100%; 10 | height: 256px; 11 | 12 | video { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | } 17 | 18 | .content { 19 | display: flex; 20 | flex-direction: column; 21 | gap: 4px; 22 | line-height: 1rem; 23 | padding: 0 6px; 24 | font-size: 0.8rem; 25 | margin-top: 6px; 26 | 27 | .hide-overflow { 28 | white-space: nowrap; 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | } 32 | 33 | .bottom { 34 | display: flex; 35 | align-items: center; 36 | justify-content: space-between; 37 | gap: 4px; 38 | font-weight: 500; 39 | color: rgba(var(--clr-secondary-values), 0.7); 40 | } 41 | 42 | .uploader { 43 | display: flex; 44 | align-items: center; 45 | gap: 5px; 46 | } 47 | 48 | .pfp { 49 | width: $pfp-size; 50 | height: $pfp-size; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/common/components/notification/notification.module.scss: -------------------------------------------------------------------------------- 1 | $pc-top: 30px; 2 | $from-top: -30%; 3 | $mobile-top: 24px; 4 | $transition: 1s ease-in-out; 5 | 6 | .notification { 7 | position: fixed; 8 | top: $from-top; 9 | left: 50%; 10 | transform: translateX(-50%); 11 | z-index: 25; 12 | font-size: 1rem; 13 | font-weight: 600; 14 | text-align: center; 15 | padding: 16px; 16 | border-radius: 8px; 17 | color: #ffffff; 18 | width: 50%; 19 | transition: top $transition; 20 | 21 | &.error { 22 | background: rgb(241, 62, 62); 23 | } 24 | 25 | &.success { 26 | background: #13b841; 27 | } 28 | 29 | &.warning { 30 | background: hsl(60, 90%, 55%); 31 | color: #3a3f3f; 32 | } 33 | 34 | &.info { 35 | background: rgb(184, 184, 184); 36 | color: #3a3f3f; 37 | } 38 | 39 | &.reveal { 40 | top: $pc-top; 41 | } 42 | 43 | &.hide { 44 | top: $from-top; 45 | } 46 | 47 | &.mobile { 48 | top: $from-top; 49 | width: 90%; 50 | font-size: 0.85rem; 51 | padding: 12px; 52 | 53 | &.reveal { 54 | top: $mobile-top; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/mobile/pages/search/search.scss: -------------------------------------------------------------------------------- 1 | .search-page { 2 | overflow-y: auto; 3 | background-color: var(--clr-background); 4 | color: var(--clr-text); 5 | padding-bottom: 12px; 6 | 7 | .content { 8 | padding: 12px 6px; 9 | 10 | .buttons { 11 | display: flex; 12 | align-items: center; 13 | justify-content: space-evenly; 14 | font-size: 0.85rem; 15 | color: rgba(var(--clr-secondary-values), 0.7); 16 | font-weight: 500; 17 | 18 | button { 19 | padding: 0 8px; 20 | 21 | &.active { 22 | box-shadow: 0 1.9px 0 0 rgba(var(--clr-secondary-values), 0.4); 23 | } 24 | } 25 | } 26 | 27 | .vid-results { 28 | display: grid; 29 | grid-template-columns: repeat(2, 1fr); 30 | gap: 6px; 31 | margin-top: 12px; 32 | } 33 | 34 | .acc-results { 35 | display: flex; 36 | flex-direction: column; 37 | gap: 20px; 38 | margin-top: 20px; 39 | } 40 | } 41 | 42 | .no-results { 43 | text-align: center; 44 | color: rgba(var(--clr-secondary-values), 0.6); 45 | margin-top: 16px; 46 | font-size: 0.8rem; 47 | } 48 | 49 | .spinner { 50 | --circle-size: 16px; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/pc/pages/search/search.scss: -------------------------------------------------------------------------------- 1 | $container-width: 700px; 2 | 3 | .search-page-container { 4 | display: flex; 5 | justify-content: space-between; 6 | gap: 5vw; 7 | min-height: 100%; 8 | } 9 | 10 | .content-container { 11 | display: flex; 12 | flex-direction: column; 13 | width: $container-width; 14 | margin-top: 12px; 15 | 16 | nav { 17 | display: flex; 18 | font-weight: 500; 19 | color: rgba(var(--clr-secondary-values), 0.5); 20 | font-size: 0.9rem; 21 | border-bottom: 2px solid var(--clr-border); 22 | 23 | span { 24 | padding: 8px 0; 25 | text-align: center; 26 | width: 120px; 27 | 28 | &.active { 29 | color: var(--clr-text); 30 | box-shadow: 0 1.8px 0 0 var(--clr-text); 31 | } 32 | } 33 | } 34 | 35 | .acc-results { 36 | padding-top: 16px; 37 | } 38 | 39 | .vid-results { 40 | display: grid; 41 | grid-template-columns: repeat(3, 1fr); 42 | gap: 16px; 43 | padding-top: 16px; 44 | 45 | &.ungrid { 46 | display: block; 47 | } 48 | } 49 | 50 | .no-results { 51 | margin-top: 12px; 52 | color: rgba(var(--clr-secondary-values), 0.75); 53 | text-align: center; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/mobile/components/navbar/navbar.scss: -------------------------------------------------------------------------------- 1 | $icon-container-size: 40px; 2 | 3 | .navbar-container { 4 | position: fixed; 5 | bottom: 0; 6 | left: 0; 7 | right: 0; 8 | z-index: 10; 9 | background-color: var(--clr-navbar-bg); 10 | color: var(--clr-text); 11 | height: var(--navbar-height); 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-between; 15 | padding: 0 16px; 16 | border-top: var(--navbar-border, none); 17 | 18 | .icon-container { 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | height: $icon-container-size; 23 | width: $icon-container-size; 24 | padding: 4px; 25 | 26 | &.active { 27 | color: var(--clr-primary); 28 | } 29 | 30 | i { 31 | font-size: 1.5rem; 32 | } 33 | } 34 | 35 | .fa-bell { 36 | position: relative; 37 | 38 | span { 39 | position: absolute; 40 | top: -2px; 41 | right: -2px; 42 | width: 6px; 43 | height: 6px; 44 | border-radius: 50%; 45 | background-color: red; 46 | } 47 | } 48 | 49 | .image-container { 50 | width: 75px; 51 | height: 100%; 52 | 53 | img { 54 | filter: var(--create-icon-filter, none); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/common/components/alert/alert.module.scss: -------------------------------------------------------------------------------- 1 | .backdrop { 2 | z-index: 11; 3 | } 4 | 5 | .alert { 6 | display: flex; 7 | flex-direction: column; 8 | gap: 12px; 9 | position: fixed; 10 | top: 50%; 11 | left: 50%; 12 | transform: translate(-50%, -50%); 13 | z-index: 12; 14 | text-align: center; 15 | background-color: var(--clr-bg-elevation-1, var(--clr-background)); 16 | color: var(--clr-text); 17 | border-radius: 8px; 18 | padding: 16px 20px 20px; 19 | box-shadow: var(--shadows, 0 0 20px 5px var(--clr-shadow)); 20 | 21 | &.mobile { 22 | width: 90%; 23 | overflow: hidden; 24 | } 25 | 26 | h3 { 27 | font-weight: 500; 28 | } 29 | 30 | p { 31 | font-size: 0.9rem; 32 | color: rgba(var(--clr-secondary-values), 0.9); 33 | } 34 | 35 | .buttons { 36 | margin-top: 4px; 37 | display: flex; 38 | flex-direction: column; 39 | gap: 6px; 40 | font-size: 0.9rem; 41 | } 42 | 43 | .primary-btn { 44 | font-weight: 400; 45 | } 46 | 47 | .secondary-btn { 48 | border: none; 49 | color: rgba(var(--clr-secondary-values), 0.6); 50 | font-weight: 400; 51 | 52 | &:hover { 53 | background-color: rgba(var(--clr-secondary-values), 0.05); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/mobile/components/unauthed-page/unauthed-page.scss: -------------------------------------------------------------------------------- 1 | $header-height: 44px; 2 | 3 | .unauthed-page { 4 | background-color: var(--clr-background); 5 | color: var(--clr-text); 6 | text-align: center; 7 | 8 | header { 9 | display: flex; 10 | height: $header-height; 11 | line-height: $header-height; 12 | font-size: 17px; 13 | font-weight: 600; 14 | padding: 0 18px; 15 | border-bottom: 2px solid var(--clr-border); 16 | 17 | > div { 18 | width: 24px; 19 | } 20 | 21 | h4 { 22 | flex: 1 1 0%; 23 | padding: 0 12px; 24 | } 25 | } 26 | 27 | .content { 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | width: 100%; 32 | height: 100%; 33 | 34 | .container { 35 | display: flex; 36 | align-items: center; 37 | flex-direction: column; 38 | gap: 24px; 39 | } 40 | 41 | i { 42 | font-size: 4.5rem; 43 | color: rgba(var(--clr-secondary-values), 0.4); 44 | } 45 | 46 | p { 47 | font-size: 0.8rem; 48 | color: rgba(var(--clr-secondary-values), 0.7); 49 | } 50 | 51 | button { 52 | width: 180px; 53 | height: 44px; 54 | border-radius: 2px; 55 | font-size: 0.9rem; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/pc/components/suggestion-card/suggestion-card.module.scss: -------------------------------------------------------------------------------- 1 | $card-width: 228px; 2 | $card-height: 304px; 3 | $pfp-size: 48px; 4 | 5 | .suggestion-card-container { 6 | width: $card-width; 7 | height: $card-height; 8 | border-radius: 8px; 9 | overflow: hidden; 10 | 11 | .video-container { 12 | position: relative; 13 | width: 100%; 14 | height: 100%; 15 | 16 | video { 17 | width: 100%; 18 | height: 100%; 19 | object-fit: cover; 20 | background-color: var(--clr-secondary); 21 | } 22 | } 23 | 24 | .info-container { 25 | position: absolute; 26 | bottom: 0; 27 | left: 0; 28 | right: 0; 29 | z-index: 5; 30 | display: flex; 31 | flex-direction: column; 32 | align-items: center; 33 | color: #fff; 34 | padding: 35px 12px 20px; 35 | text-align: center; 36 | 37 | .rounded-photo { 38 | width: $pfp-size; 39 | height: $pfp-size; 40 | } 41 | 42 | strong { 43 | margin-top: 8px; 44 | font-size: 1rem; 45 | } 46 | 47 | p { 48 | font-size: 0.85rem; 49 | font-weight: 500; 50 | } 51 | 52 | button { 53 | margin-top: 6px; 54 | padding: 0; 55 | height: 36px; 56 | font-weight: 700; 57 | width: 90%; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/mobile/components/notification-box/notification-box.module.scss: -------------------------------------------------------------------------------- 1 | $pfp-size: 48px; 2 | 3 | .notif-box { 4 | display: flex; 5 | gap: 12px; 6 | padding: 12px; 7 | 8 | &.unread { 9 | background: rgba(var(--clr-secondary-values), 0.07); 10 | } 11 | 12 | .pfp { 13 | width: $pfp-size; 14 | height: $pfp-size; 15 | flex-shrink: 0; 16 | } 17 | 18 | .content { 19 | display: flex; 20 | flex-direction: column; 21 | gap: 4px; 22 | font-size: 0.9rem; 23 | line-height: 0.9rem; 24 | width: 100%; 25 | 26 | .username { 27 | font-weight: 600; 28 | } 29 | 30 | p { 31 | color: rgba(var(--clr-secondary-values), 0.7); 32 | line-height: 1.1rem; 33 | font-size: 0.85rem; 34 | hyphens: none; 35 | } 36 | 37 | span { 38 | color: rgba(var(--clr-secondary-values), 0.6); 39 | font-size: 0.75rem; 40 | } 41 | } 42 | 43 | .box-end { 44 | display: flex; 45 | align-items: center; 46 | gap: 12px; 47 | 48 | i { 49 | font-size: 1rem; 50 | color: rgba(var(--clr-secondary-values), 0.5); 51 | } 52 | } 53 | 54 | .video-container { 55 | width: 42px; 56 | height: 56px; 57 | flex-shrink: 0; 58 | 59 | video { 60 | width: 100%; 61 | height: 100%; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 25 | TikTok 26 | 27 | 28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /src/mobile/components/unauthed-page/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import PageWithNavbar from "../page-with-navbar"; 4 | import "./unauthed-page.scss"; 5 | import { ComponentProps } from "../../../common/types"; 6 | import { useAppDispatch } from "../../../common/store"; 7 | import { authModalActions } from "../../../common/store/slices/auth-modal-slice"; 8 | 9 | interface Props extends ComponentProps { 10 | header: string; 11 | options?: ReactNode; 12 | icon: ReactNode; 13 | description: string; 14 | } 15 | 16 | export default function UnauthedPage({ 17 | header, 18 | icon, 19 | description, 20 | options 21 | }: Props) { 22 | const dispatch = useAppDispatch(); 23 | 24 | function handleAuthModalOpen() { 25 | dispatch(authModalActions.showModal()); 26 | } 27 | 28 | return ( 29 | 30 |
31 |
32 |

{header}

33 |
{options}
34 |
35 |
36 |
37 | {icon} 38 |

{description}

39 | 42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/common/components/legal-notice/index.tsx: -------------------------------------------------------------------------------- 1 | import Alert from "../alert"; 2 | import "./legal-notice.scss"; 3 | 4 | interface Props { 5 | setShowNotice: React.Dispatch>; 6 | isMobile?: boolean; 7 | } 8 | 9 | export default function LegalNotice({ setShowNotice, isMobile }: Props) { 10 | return ( 11 | 15 | This open source project is not affiliated with TikTok or ByteDance in 16 | any way and should not be confused with the former. 17 |
18 |
This project is licenced under 19 | 24 | Creative Commons. 25 | 26 | Contact 27 | 28 | shrutanten.work@gmail.com 29 | 30 | for legal stuff. 31 |
32 |
TikTok, TikTok logo and basically everything used here is a 33 | trademark of ByteDance. 34 | 35 | } 36 | containerClassName="legal-notice" 37 | primaryButtonText="Okay, got it" 38 | primaryButtonFn={() => setShowNotice(false)} 39 | isMobile={isMobile} 40 | /> 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/mobile/store/slices/navbar-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; 2 | import { hasNewNotifs } from "../../../common/api/user"; 3 | 4 | interface InitState { 5 | hasNewNotifs: boolean; 6 | } 7 | 8 | const initialState: InitState = { hasNewNotifs: false }; 9 | 10 | const fetchNewNotifs = createAsyncThunk( 11 | "navbar/fetchNewNotifs", 12 | async ({ username, token }: { username: string; token: string }) => { 13 | const res = await hasNewNotifs(username, token); 14 | return res.data; 15 | } 16 | ); 17 | 18 | const navbarSlice = createSlice({ 19 | name: "navbar", 20 | initialState, 21 | reducers: { 22 | hasReadNotifs(state) { 23 | state.hasNewNotifs = false; 24 | } 25 | }, 26 | extraReducers: builder => { 27 | builder.addCase(fetchNewNotifs.fulfilled, (state, action) => { 28 | state.hasNewNotifs = action.payload.hasNew; 29 | }); 30 | 31 | builder.addCase(fetchNewNotifs.pending, state => { 32 | state.hasNewNotifs = false; 33 | }); 34 | 35 | builder.addCase(fetchNewNotifs.rejected, (state, action) => { 36 | state.hasNewNotifs = false; 37 | console.error(action.error); 38 | }); 39 | } 40 | }); 41 | 42 | export default navbarSlice.reducer; 43 | export const navbarActions = navbarSlice.actions; 44 | export { fetchNewNotifs }; 45 | -------------------------------------------------------------------------------- /src/mobile/index.scss: -------------------------------------------------------------------------------- 1 | // cur-max-z-index: 25 (notification) 2 | 3 | :root { 4 | --navbar-height: 49px; 5 | } 6 | 7 | :root.light { 8 | --create-icon-filter: invert(1); 9 | --clr-navbar-bg: var(--clr-background); 10 | --navbar-border: 1px solid #d0d0d3; 11 | } 12 | 13 | :root.dark { 14 | --clr-navbar-bg: #000; 15 | --navbar-border: 1px solid #696969; 16 | } 17 | 18 | html, 19 | body { 20 | overflow: hidden; 21 | height: 100%; // 100vh causes problems 22 | } 23 | 24 | #root { 25 | height: 100%; 26 | } 27 | 28 | .root-container { 29 | display: flex; 30 | flex-direction: column; 31 | width: 100vw; 32 | height: 100%; 33 | } 34 | 35 | .fade-out { 36 | opacity: 0; 37 | } 38 | 39 | .fade-in { 40 | opacity: 1; 41 | } 42 | 43 | .primary-button { 44 | color: #ffffff; 45 | background-color: var(--clr-primary); 46 | border-radius: 4px; 47 | padding: 8px; 48 | font-weight: 600; 49 | border: 1px solid var(--clr-primary); 50 | 51 | &:disabled { 52 | border: 1px solid rgba(var(--clr-secondary-values), 0.06); 53 | background: rgba(var(--clr-secondary-values), 0.06); 54 | color: rgba(var(--clr-secondary-values), 0.34); 55 | } 56 | } 57 | 58 | .secondary-button { 59 | color: var(--clr-primary); 60 | font-weight: 600; 61 | padding: 8px; 62 | border-radius: 4px; 63 | border: 1px solid var(--clr-primary); 64 | } 65 | -------------------------------------------------------------------------------- /src/pc/components/video-card/useVideoDynamics.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from "react"; 2 | 3 | import { VideoDynamics } from "../../../common/types"; 4 | 5 | export const videoDynamicsActions = { 6 | LIKED: "liked", 7 | FOLLOWED: "followed", 8 | COMMENTED: "commented", 9 | ALL: "all" 10 | }; 11 | 12 | type ActionType = { 13 | type: string; 14 | hasLiked?: boolean; 15 | isFollowing?: boolean; 16 | commentsNum?: number; 17 | payload?: VideoDynamics; 18 | }; 19 | 20 | function videoDynamicsReducer( 21 | state: VideoDynamics, 22 | action: ActionType 23 | ): VideoDynamics { 24 | switch (action.type) { 25 | case videoDynamicsActions.LIKED: 26 | return { 27 | ...state, 28 | hasLiked: action.hasLiked!, 29 | likesNum: state.likesNum + (action.hasLiked ? 1 : -1) 30 | }; 31 | 32 | case videoDynamicsActions.FOLLOWED: 33 | return { 34 | ...state, 35 | isFollowing: action.isFollowing 36 | }; 37 | 38 | case videoDynamicsActions.COMMENTED: 39 | return { 40 | ...state, 41 | commentsNum: action.commentsNum! 42 | }; 43 | 44 | case videoDynamicsActions.ALL: 45 | return { ...action.payload! }; 46 | 47 | default: 48 | return state; 49 | } 50 | } 51 | 52 | export default function useVideoDynamics(initState: VideoDynamics) { 53 | return useReducer(videoDynamicsReducer, initState); 54 | } 55 | -------------------------------------------------------------------------------- /src/pc/components/user-dropdown/user-dropdown.scss: -------------------------------------------------------------------------------- 1 | $dropdown-width: 296px; 2 | $dropdown-pfp-size: 42px; 3 | 4 | .user-dropdown { 5 | z-index: 9; 6 | transform: translateY(calc(100% + 10px)); 7 | padding: 24px; 8 | width: $dropdown-width; 9 | 10 | &.hide { 11 | opacity: 0; 12 | } 13 | 14 | .top { 15 | display: flex; 16 | align-items: center; 17 | justify-content: space-between; 18 | } 19 | 20 | .follow-btn { 21 | margin: 0; 22 | } 23 | 24 | .rounded-photo { 25 | width: $dropdown-pfp-size; 26 | height: $dropdown-pfp-size; 27 | } 28 | 29 | .dd-card-names { 30 | .names-header { 31 | flex-direction: column; 32 | gap: 0; 33 | align-items: stretch; 34 | margin: 8px 0; 35 | 36 | .username { 37 | font-size: 1rem; 38 | cursor: unset; 39 | font-weight: 600; 40 | 41 | &:hover { 42 | text-decoration: none; 43 | } 44 | } 45 | 46 | h5 { 47 | font-weight: 400; 48 | margin-bottom: 4px; 49 | } 50 | } 51 | } 52 | 53 | .counts { 54 | display: flex; 55 | gap: 12px; 56 | border-bottom: 2px solid var(--clr-border); 57 | padding: 16px 0; 58 | 59 | p { 60 | font-weight: 400; 61 | 62 | span { 63 | font-weight: 600; 64 | } 65 | } 66 | } 67 | 68 | .description { 69 | font-weight: 400; 70 | font-size: 0.8rem; 71 | padding-top: 16px; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/pc/components/profile-card/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, Suspense, lazy } from "react"; 2 | 3 | import "./profile-card.scss"; 4 | import FullscreenSpinner from "../../../common/components/fullscreen-spinner"; 5 | import { modifyScrollbar } from "../../../common/utils"; 6 | import constants from "../../../common/constants"; 7 | const LoadVideoModal = lazy( 8 | () => import("../../components/video-modal/LoadVideoModal") 9 | ); 10 | 11 | export default function ProfileCard({ videoId }: { videoId: string }) { 12 | const [showModal, setShowModal] = useState(false); 13 | 14 | function handleModalOpen() { 15 | modifyScrollbar("hide"); 16 | setShowModal(true); 17 | } 18 | 19 | return ( 20 |
21 | {showModal && ( 22 | }> 23 | 24 | 25 | )} 26 |
27 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/pc/components/video-modal/Likes.tsx: -------------------------------------------------------------------------------- 1 | import ActionButton from "../action-button"; 2 | import { useAppSelector, useAppDispatch } from "../../../common/store"; 3 | import { likeVideo } from "../../../common/api/video"; 4 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 5 | import { joinClasses } from "../../../common/utils"; 6 | 7 | interface Props { 8 | likes: number; 9 | handleAuthModalOpen: () => void; 10 | curVidId: string; 11 | hasLiked?: boolean; 12 | onClick: (liked: boolean) => void; 13 | } 14 | 15 | export default function Likes(props: Props) { 16 | const { 17 | isAuthenticated: isAuthed, 18 | username, 19 | token 20 | } = useAppSelector(state => state.auth); 21 | const dispatch = useAppDispatch(); 22 | 23 | async function likeVid() { 24 | if (!isAuthed) return props.handleAuthModalOpen(); 25 | try { 26 | const res = await likeVideo(username!, props.curVidId, token!); 27 | props.onClick(res.data.liked); 28 | } catch (err: any) { 29 | dispatch( 30 | notificationActions.showNotification({ 31 | type: "error", 32 | message: err.message 33 | }) 34 | ); 35 | } 36 | } 37 | 38 | return ( 39 | } 41 | number={props.likes} 42 | className={joinClasses("action-btn-container", props.hasLiked && "liked")} 43 | onClick={likeVid} 44 | /> 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/mobile/components/search-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import { useFormik } from "formik"; 2 | import * as yup from "yup"; 3 | 4 | import Input from "../../../common/components/input-field"; 5 | import classes from "./search-bar.module.scss"; 6 | import constants from "../../../common/constants"; 7 | 8 | interface Props { 9 | autoFocus: boolean; 10 | query: string; 11 | setQuery: React.Dispatch>; 12 | } 13 | 14 | const validationSchema = yup.object().shape({ 15 | query: yup.string().required("").max(constants.searchQueryMaxLen, "") 16 | }); 17 | 18 | export default function SearchBar({ autoFocus, query, setQuery }: Props) { 19 | const formik = useFormik({ 20 | initialValues: { query }, 21 | validationSchema, 22 | onSubmit: values => { 23 | setQuery(values.query); 24 | } 25 | }); 26 | 27 | return ( 28 |
29 | 40 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/mobile/pages/video/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | 4 | import PageWithNavbar from "../../components/page-with-navbar"; 5 | import "./video.scss"; 6 | import Video from "../../components/video"; 7 | import { VideoData } from "../../../common/types"; 8 | import { errorNotification } from "../../helpers/error-notification"; 9 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 10 | import { getVideo } from "../../../common/api/video"; 11 | import Swiper from "../../components/swiper"; 12 | import FullscreenSpinner from "../../../common/components/fullscreen-spinner"; 13 | 14 | export default function VideoPage() { 15 | const { videoId } = useParams(); 16 | const dispatch = useAppDispatch(); 17 | const username = useAppSelector(state => state.auth.username); 18 | const [vid, setVid] = useState(null); 19 | 20 | useEffect(() => { 21 | errorNotification( 22 | async () => { 23 | if (!videoId) throw new Error("Invalid URL"); 24 | const res = await getVideo(videoId, username); 25 | delete res.data.success; 26 | setVid(res.data); 27 | }, 28 | dispatch, 29 | null, 30 | "Couldn't load video:" 31 | ); 32 | }, [dispatch, videoId, username]); 33 | 34 | return ( 35 | 36 | {!vid ? : ]} />} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/pc/components/header/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router-dom"; 2 | import { useFormik } from "formik"; 3 | import * as yup from "yup"; 4 | 5 | import classes from "./header.module.scss"; 6 | import constants from "../../../common/constants"; 7 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 8 | import { searchActions } from "../../store/slices/search-slice"; 9 | 10 | const validationSchema = yup.object().shape({ 11 | query: yup.string().required("").max(constants.searchQueryMaxLen, "") 12 | }); 13 | 14 | export default function SearchBar() { 15 | const dispatch = useAppDispatch(); 16 | const storeQuery = useAppSelector(state => state.pc.search.query); 17 | const navigate = useNavigate(); 18 | const formik = useFormik({ 19 | initialValues: { 20 | query: storeQuery 21 | }, 22 | enableReinitialize: true, 23 | validationSchema, 24 | onSubmit: ({ query }) => { 25 | dispatch(searchActions.putQuery(query)); 26 | navigate("/search?query=" + query); 27 | } 28 | }); 29 | 30 | return ( 31 |
32 | 41 | 42 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/common/components/dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, ReactNode } from "react"; 2 | 3 | import classes from "./dropdown.module.scss"; 4 | import { joinClasses, handleClickOutside } from "../../utils"; 5 | import { ComponentProps } from "../../types"; 6 | 7 | export interface DropdownProps extends ComponentProps { 8 | children: ReactNode; 9 | setShowDropdown?: React.Dispatch>; 10 | trigger?: "click" | "hover"; 11 | onMouseOver?: () => void; 12 | onMouseOut?: () => void; 13 | } 14 | 15 | export const DDAnimationTime = 100; 16 | 17 | export default function Dropdown({ 18 | className, 19 | children, 20 | setShowDropdown, 21 | trigger = "click", 22 | onMouseOver, 23 | onMouseOut 24 | }: DropdownProps) { 25 | const dropdownRef = useRef(null); 26 | 27 | useEffect(() => { 28 | if (trigger !== "click") return; 29 | const dropdown = dropdownRef.current!; 30 | let timeOut: NodeJS.Timeout; 31 | 32 | const eventRemover = handleClickOutside(dropdown, () => { 33 | dropdown.classList.add(classes["hide"]); 34 | timeOut = setTimeout(() => setShowDropdown!(false), DDAnimationTime); 35 | }); 36 | 37 | return () => { 38 | eventRemover(); 39 | clearTimeout(timeOut); 40 | }; 41 | }, [setShowDropdown, trigger]); 42 | 43 | return ( 44 |
50 | {children} 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/common/components/input-field/input.module.scss: -------------------------------------------------------------------------------- 1 | .input-wrapper { 2 | background-color: inherit; 3 | color: inherit; 4 | 5 | .input-field { 6 | position: relative; 7 | color: var(--clr-text); 8 | background: rgba(var(--clr-secondary-values), 0.06); 9 | border: 1.5px solid rgba(var(--clr-secondary-values), 0.06); 10 | border-radius: 4px; 11 | width: 100%; 12 | 13 | &:hover { 14 | background: rgba(var(--clr-secondary-values), 0.1); 15 | border: 1.5px solid rgba(var(--clr-secondary-values), 0.1); 16 | } 17 | 18 | &.error { 19 | border: 1.5px solid red; 20 | } 21 | 22 | input { 23 | caret-color: var(--clr-primary); 24 | padding: 10px 12px; 25 | font-size: 0.9rem; 26 | width: 100%; 27 | 28 | &::placeholder { 29 | color: rgba(var(--clr-secondary-values), 0.6); 30 | } 31 | } 32 | } 33 | 34 | .error-container { 35 | color: red; 36 | font-size: 0.75rem; 37 | margin-left: 4px; 38 | } 39 | } 40 | 41 | // mobile version 42 | .mobile-input-wrapper { 43 | display: flex; 44 | flex-direction: column; 45 | width: 100%; 46 | 47 | .input-field { 48 | display: flex; 49 | background-color: rgba(var(--clr-secondary-values), 0.06); 50 | width: 100%; 51 | padding: 6px 10px; 52 | border-radius: 8px; 53 | border: 1px solid transparent; 54 | caret-color: var(--clr-primary); 55 | 56 | &.error { 57 | border: 1px solid red; 58 | } 59 | 60 | input { 61 | width: 100%; 62 | } 63 | } 64 | 65 | .error-container { 66 | color: red; 67 | margin-left: 8px; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tiktok", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reduxjs/toolkit": "^1.6.2", 7 | "@testing-library/jest-dom": "^5.15.0", 8 | "@testing-library/react": "^11.2.7", 9 | "@testing-library/user-event": "^12.8.3", 10 | "@types/jest": "^26.0.24", 11 | "@types/node": "^12.20.37", 12 | "@types/react": "^17.0.34", 13 | "@types/react-dom": "^17.0.11", 14 | "axios": "^0.24.0", 15 | "copy-to-clipboard": "^3.3.1", 16 | "formik": "^2.2.9", 17 | "node-sass": "^6.0.1", 18 | "react": "^17.0.2", 19 | "react-dom": "^17.0.2", 20 | "react-infinite-scroll-component": "^6.1.0", 21 | "react-redux": "^7.2.6", 22 | "react-router-dom": "^6.0.2", 23 | "react-scripts": "4.0.3", 24 | "socket.io-client": "^4.4.1", 25 | "swiper": "^7.4.1", 26 | "typescript": "^4.4.4", 27 | "web-vitals": "^1.1.2", 28 | "yup": "^0.32.11" 29 | }, 30 | "scripts": { 31 | "start": "react-scripts start", 32 | "build": "react-scripts build", 33 | "test": "react-scripts test", 34 | "eject": "react-scripts eject" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ] 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/pc/components/search-results/VideoCard.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import classes from "./search-results.module.scss"; 4 | import { VideoData } from "../../../common/types"; 5 | import { joinClasses } from "../../../common/utils"; 6 | import constants from "../../../common/constants"; 7 | 8 | export default function VideoCard(props: VideoData) { 9 | return ( 10 | 14 |
15 |
23 |
24 |

25 | {props.caption}  26 | {props.tags!.map((tag, i) => ( 27 | #{tag}  28 | ))} 29 |

30 |
31 |
32 |
33 | {props.uploader!.username} 37 |
38 |
{props.uploader!.username}
39 |
40 |
41 | {props.views} 42 |
43 |
44 |
45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/pc/components/user-dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | import Dropdown from "./DD"; 4 | import { DDAnimationTime } from "../../../common/components/dropdown"; 5 | 6 | interface Props { 7 | onMouseOver: () => void; 8 | onMouseOut: () => void; 9 | onFollow?: (followed: boolean) => void; 10 | showDropdown: boolean; 11 | username: string; 12 | } 13 | 14 | const DDTimeThreshold = 600; // time after which dropdown gets unmounted 15 | let DDMountTimeout: NodeJS.Timeout, 16 | DDHideTimeout: NodeJS.Timeout, 17 | DDUnmountTimeout: NodeJS.Timeout; 18 | 19 | export default function UserDropdown(props: Props) { 20 | const [showDD, setShowDD] = useState(false); 21 | const { showDropdown } = props; 22 | 23 | useEffect(() => { 24 | if (showDropdown) { 25 | clearTimeout(DDHideTimeout); 26 | clearTimeout(DDUnmountTimeout); 27 | DDMountTimeout = setTimeout(() => setShowDD(true), DDTimeThreshold); 28 | } else { 29 | clearTimeout(DDMountTimeout); 30 | const card = document.querySelector(".user-dropdown"); 31 | if (card) { 32 | // hide timeout 33 | DDHideTimeout = setTimeout( 34 | () => card.classList.add("hide"), 35 | DDTimeThreshold 36 | ); 37 | // remove timeout 38 | DDUnmountTimeout = setTimeout( 39 | () => setShowDD(false), 40 | DDAnimationTime + DDTimeThreshold 41 | ); 42 | } 43 | } 44 | 45 | return () => { 46 | clearTimeout(DDMountTimeout); 47 | clearTimeout(DDHideTimeout); 48 | clearTimeout(DDUnmountTimeout); 49 | }; 50 | }, [showDropdown]); 51 | 52 | return showDD ? : null; 53 | } 54 | -------------------------------------------------------------------------------- /src/common/components/notification/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | import classes from "./notification.module.scss"; 4 | import { useAppDispatch } from "../../store"; 5 | import { joinClasses } from "../../utils"; 6 | import { notificationActions } from "../../store/slices/notification-slice"; 7 | 8 | export interface NotificationProps { 9 | type: "success" | "error" | "warning" | "info"; 10 | message: string; 11 | isMobile?: boolean; 12 | } 13 | 14 | const notifDuration = 5000; // stays visible for 5s 15 | const notifAnimDuration = 1000; // hide animation plays for 1s 16 | 17 | export default function Notification({ 18 | type, 19 | message, 20 | isMobile 21 | }: NotificationProps) { 22 | const dispatch = useAppDispatch(); 23 | const notifRef = useRef(null); 24 | 25 | useEffect(() => { 26 | if (!notifRef.current) return; 27 | const classList = notifRef.current.classList; 28 | setTimeout(() => classList.add(classes["reveal"]), 10); 29 | 30 | const hideTimeout = setTimeout(() => { 31 | classList.remove(classes["reveal"]); 32 | classList.add(classes["hide"]); 33 | }, notifDuration); 34 | const removeTimeout = setTimeout(() => { 35 | dispatch(notificationActions.hideNotification()); 36 | }, notifAnimDuration + notifDuration); 37 | 38 | return () => { 39 | clearTimeout(hideTimeout); 40 | clearTimeout(removeTimeout); 41 | }; 42 | }, [dispatch]); 43 | 44 | return ( 45 |
53 | {message} 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/pc/components/search-results/search-results.module.scss: -------------------------------------------------------------------------------- 1 | $acc-pfp-size: 60px; 2 | $vid-pfp-size: 24px; 3 | $video-container-height: 313px; 4 | 5 | .account-card { 6 | display: flex; 7 | padding: 16px 0; 8 | 9 | .pfp { 10 | flex: 0 0 64px; 11 | margin: 0 16px 16px 16px; 12 | width: $acc-pfp-size; 13 | height: $acc-pfp-size; 14 | } 15 | 16 | .card-content { 17 | display: flex; 18 | flex-direction: column; 19 | gap: 4px; 20 | 21 | h3 { 22 | font-size: 1rem; 23 | font-weight: 700; 24 | line-height: 1rem; 25 | } 26 | 27 | h5 { 28 | font-size: 0.8rem; 29 | font-weight: 400; 30 | color: rgba(var(--clr-secondary-values), 0.75); 31 | 32 | span { 33 | font-weight: 500; 34 | color: var(--clr-text); 35 | } 36 | } 37 | 38 | p { 39 | font-size: 0.8rem; 40 | font-weight: 400; 41 | } 42 | } 43 | } 44 | 45 | .video-card { 46 | display: flex; 47 | flex-direction: column; 48 | 49 | .video-container { 50 | overflow: hidden; 51 | border-radius: 4px; 52 | width: 100%; 53 | height: $video-container-height; 54 | 55 | video { 56 | width: 100%; 57 | height: 100%; 58 | object-fit: cover; 59 | } 60 | } 61 | 62 | .card-content { 63 | padding: 8px 0; 64 | color: rgba(var(--clr-secondary-values), 0.75); 65 | font-size: 0.9rem; 66 | 67 | .info { 68 | display: flex; 69 | justify-content: space-between; 70 | align-items: center; 71 | margin-top: 6px; 72 | 73 | .uploader { 74 | display: flex; 75 | align-items: center; 76 | gap: 4px; 77 | font-size: 1rem; 78 | } 79 | } 80 | 81 | .pfp { 82 | width: $vid-pfp-size; 83 | height: $vid-pfp-size; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/common/components/auth-modal/auth-modal.scss: -------------------------------------------------------------------------------- 1 | $modal-width: 464px; 2 | $button-height: 42px; 3 | 4 | .auth-modal-container { 5 | position: fixed; 6 | top: 50%; 7 | left: 50%; 8 | transform: translate(-50%, -50%); 9 | z-index: 23; 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | height: 90vh; 14 | width: $modal-width; 15 | background: var(--clr-bg-elevation-1, var(--clr-background)); 16 | color: var(--clr-text); 17 | border-radius: 8px; 18 | padding: 16px 42px; 19 | overflow: auto; 20 | 21 | &.mobile { 22 | inset: 0; 23 | transform: none; 24 | padding: 16px; 25 | width: 100vw; 26 | height: 100%; 27 | 28 | .fa-close { 29 | position: absolute; 30 | right: 24px; 31 | top: 17px; 32 | font-size: 1.3rem; 33 | color: rgba(var(--clr-secondary-values), 0.7); 34 | } 35 | } 36 | 37 | h1 { 38 | font-size: 1.4rem; 39 | font-weight: 700; 40 | margin-top: 20px; 41 | } 42 | 43 | form { 44 | display: flex; 45 | flex-direction: column; 46 | gap: 10px; 47 | margin: 24px 0; 48 | width: 100%; 49 | 50 | h3 { 51 | font-weight: 600; 52 | font-size: 1rem; 53 | } 54 | 55 | button { 56 | margin-top: 16px; 57 | height: $button-height; 58 | } 59 | } 60 | 61 | .switch-state { 62 | background-color: inherit; 63 | margin-top: auto; 64 | width: 100%; 65 | padding: 16px 0 4px; 66 | font-size: 0.9rem; 67 | border-top: 2px solid var(--clr-border); 68 | 69 | span { 70 | color: var(--clr-primary); 71 | font-weight: 600; 72 | cursor: pointer; 73 | user-select: none; 74 | } 75 | } 76 | 77 | .auth-spinner { 78 | --circle-size: 14px; 79 | --wrapper-padding: 0; 80 | } 81 | } 82 | 83 | .auth-backdrop { 84 | z-index: 22; 85 | } 86 | -------------------------------------------------------------------------------- /src/common/components/auth-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useLayoutEffect } from "react"; 2 | 3 | import "./auth-modal.scss"; 4 | import LogIn from "./LogIn"; 5 | import SignUp from "./SignUp"; 6 | import { useAppDispatch } from "../../store"; 7 | import { joinClasses, modifyScrollbar } from "../../utils"; 8 | import { authModalActions } from "../../store/slices/auth-modal-slice"; 9 | import { ComponentProps } from "../../types"; 10 | 11 | export interface FormProps { 12 | setAuthType: React.Dispatch>; 13 | handleModalClose: () => void; 14 | } 15 | 16 | interface Props extends ComponentProps { 17 | isMobile?: boolean; 18 | } 19 | 20 | export default function AuthModal({ isMobile, className }: Props) { 21 | const [authType, setAuthType] = useState<"login" | "signup">("login"); 22 | const dispatch = useAppDispatch(); 23 | 24 | useLayoutEffect(() => { 25 | if (isMobile) return; 26 | modifyScrollbar("hide"); 27 | }, [isMobile]); 28 | 29 | function handleModalClose() { 30 | if (!isMobile) modifyScrollbar("show"); 31 | dispatch(authModalActions.hideModal()); 32 | } 33 | 34 | return ( 35 | <> 36 | {!isMobile && ( 37 |
38 | )} 39 |
46 | {isMobile && } 47 | {authType === "login" ? ( 48 | 52 | ) : ( 53 | 57 | )} 58 |
59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/common/components/alert/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { createPortal } from "react-dom"; 3 | 4 | import classes from "./alert.module.scss"; 5 | import { joinClasses } from "../../utils"; 6 | 7 | interface Props { 8 | header: string; 9 | description: ReactNode; 10 | primaryButtonText: string; 11 | primaryButtonFn?: (e?: any) => void; 12 | secondaryButtonText?: string; 13 | secondaryButtonFn?: (e?: any) => void; 14 | backdropClassName?: string; 15 | containerClassName?: string; 16 | setShowAlert?: React.Dispatch>; 17 | isMobile?: boolean; 18 | } 19 | 20 | export default function Alert(props: Props) { 21 | return createPortal( 22 | <> 23 |
props.setShowAlert?.(false)} 30 | /> 31 |
38 |

{props.header}

39 |

{props.description}

40 |
41 | 47 | {props.secondaryButtonText && ( 48 | 57 | )} 58 |
59 |
60 | , 61 | document.querySelector("#portal")! 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/pc/components/suggestion-card/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import classes from "./suggestion-card.module.scss"; 5 | import FollowButton from "../follow-button"; 6 | import { UserData, ComponentProps } from "../../../common/types"; 7 | import { joinClasses } from "../../../common/utils"; 8 | import constants from "../../../common/constants"; 9 | 10 | interface CardProps extends UserData, ComponentProps {} 11 | 12 | export default function SuggestionCard(props: CardProps) { 13 | const videoRef = useRef(null); 14 | 15 | return ( 16 | 17 |
videoRef.current?.play()} 23 | onMouseOut={() => videoRef.current?.pause()} 24 | > 25 |
26 |
56 |
57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | export function joinClasses( 2 | ...classes: Array 3 | ) { 4 | let res = ""; 5 | for (let i = 0; i < classes.length - 1; i++) { 6 | if (classes[i]) res += classes[i] + " "; 7 | } 8 | if (classes[classes.length - 1]) res += classes[classes.length - 1]; 9 | return res; 10 | } 11 | 12 | export function modifyScrollbar(fn: "hide" | "show") { 13 | const scrollbarWidth = 14 | window.innerWidth - document.documentElement.clientWidth || 8; 15 | const styleObj = document.documentElement.style; 16 | const header = document.querySelector(".app-header header") as HTMLElement; 17 | if (fn === "hide") { 18 | styleObj.overflowY = "hidden"; 19 | styleObj.paddingRight = scrollbarWidth + "px"; 20 | header.style.paddingRight = scrollbarWidth + "px"; 21 | } else { 22 | styleObj.overflowY = "auto"; 23 | styleObj.paddingRight = "unset"; 24 | header.style.paddingRight = "unset"; 25 | } 26 | } 27 | 28 | export function handleClickOutside( 29 | element: HTMLElement | null, 30 | cb: () => void 31 | ) { 32 | function listener(event: MouseEvent) { 33 | if (element == null || element.contains(event.target as Node)) return; 34 | cb(); 35 | } 36 | document.addEventListener("click", listener); 37 | return () => document.removeEventListener("click", listener); 38 | } 39 | 40 | export function convertToDate(date: string | Date) { 41 | return new Date(date).toLocaleString(["en-IN", "en-GB", "en"], { 42 | day: "numeric", 43 | month: "short", 44 | hour: "numeric", 45 | minute: "numeric", 46 | hour12: true 47 | }); 48 | } 49 | 50 | export function getTimeFromSeconds(seconds: number) { 51 | const mins = Math.floor(seconds / 60); 52 | const secs = Math.round(seconds % 60); 53 | const str = `${mins < 10 ? "0" + mins : mins}:${ 54 | secs < 10 ? "0" + secs : secs 55 | }`; 56 | return str; 57 | } 58 | -------------------------------------------------------------------------------- /src/common/api/user.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "."; 2 | import { UserQuery } from "../types"; 3 | 4 | const userURL = "/user"; 5 | 6 | const shortParams: UserQuery = { 7 | name: "1", 8 | description: "1", 9 | followers: "num", 10 | totalLikes: "1" 11 | }; 12 | export const getShortUser = (username: string, loggedInAs?: string | null) => 13 | apiClient.get(userURL + "/" + username, { 14 | params: { ...shortParams, loggedInAs } 15 | }); 16 | 17 | const params: UserQuery = { 18 | ...shortParams, 19 | following: "num", 20 | videos: "uploaded" 21 | }; 22 | export const getUser = (username: string, loggedInAs?: string | null) => 23 | apiClient.get(userURL + "/" + username, { 24 | params: { ...params, loggedInAs } 25 | }); 26 | 27 | export const getCustom = (params: UserQuery, username: string) => 28 | apiClient.get(userURL + "/" + username, { params }); 29 | 30 | export const getLikedVideos = (username: string) => 31 | apiClient.get(userURL + "/" + username, { params: { videos: "liked" } }); 32 | 33 | export const followUser = ( 34 | toFollow: string, 35 | loggedInAs: string | null, 36 | token: string 37 | ) => apiClient.post(userURL + "/follow", { toFollow, loggedInAs, token }); 38 | 39 | export const updateUser = (username: string, data: any) => 40 | apiClient.patch(userURL + "/" + username, data); 41 | 42 | const notifRoute = "/notifications"; 43 | 44 | export const hasNewNotifs = (username: string, token: string) => 45 | apiClient.post(userURL + notifRoute + "/hasNew", { username, token }); 46 | 47 | export const readAllNotifs = (username: string, token: string) => 48 | apiClient.post(userURL + notifRoute + "/readAll", { username, token }); 49 | 50 | export const deleteNotif = ( 51 | username: string, 52 | token: string, 53 | notificationId: string 54 | ) => 55 | apiClient.delete(userURL + notifRoute, { 56 | data: { username, token, notificationId } 57 | }); 58 | -------------------------------------------------------------------------------- /src/common/components/input-field/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, forwardRef, ForwardedRef } from "react"; 2 | 3 | import classes from "./input.module.scss"; 4 | import { joinClasses } from "../../utils"; 5 | 6 | interface InputProps { 7 | type?: "text" | "password" | "email"; 8 | placeholder?: string; 9 | id?: string; 10 | className?: string; 11 | wrapperClassName?: string; 12 | icon?: ReactNode; 13 | onChange?: (a: any) => void; 14 | onBlur?: (a: any) => void; 15 | value?: string; 16 | name?: string; 17 | error?: string | false; 18 | autoComplete?: string; 19 | isMobile?: boolean; 20 | autoFocus?: boolean; 21 | } 22 | 23 | function Input( 24 | { 25 | type = "text", 26 | placeholder, 27 | id, 28 | className, 29 | wrapperClassName, 30 | icon, 31 | onChange, 32 | onBlur, 33 | value, 34 | name, 35 | error, 36 | autoComplete, 37 | isMobile, 38 | autoFocus 39 | }: InputProps, 40 | ref: ForwardedRef 41 | ) { 42 | return ( 43 |
49 |
56 | {icon} 57 | 70 |
71 | {error && ( 72 |
75 | {error} 76 |
77 | )} 78 |
79 | ); 80 | } 81 | 82 | export default forwardRef(Input); 83 | -------------------------------------------------------------------------------- /src/mobile/components/swiper/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback } from "react"; 2 | import SwiperType from "swiper"; 3 | import { 4 | Swiper, 5 | SwiperProps, 6 | SwiperSlide, 7 | SwiperSlideProps 8 | } from "swiper/react/swiper-react"; 9 | import "swiper/swiper.scss"; 10 | 11 | import classes from "./swiper.module.scss"; 12 | import { joinClasses } from "../../../common/utils"; 13 | 14 | interface Props { 15 | containerClassName?: string; 16 | containerProps?: SwiperProps; 17 | slideClassName?: string; 18 | slideProps?: SwiperSlideProps; 19 | slides: ReactNode[]; 20 | fetchNext?: () => void; 21 | } 22 | 23 | const isSafari = /iPhone|Mac OS|iPad|iPod/.test(navigator.userAgent); 24 | 25 | export default function SwiperComponent({ 26 | containerClassName, 27 | slideClassName, 28 | slides, 29 | containerProps, 30 | slideProps, 31 | fetchNext 32 | }: Props) { 33 | const handleSlideChange = useCallback( 34 | (swiper: SwiperType) => { 35 | const prevVideo = document.querySelector( 36 | "#slide-" + swiper.previousIndex + " video" 37 | )!; 38 | const curVideo = document.querySelector( 39 | "#slide-" + swiper.activeIndex + " video" 40 | )!; 41 | 42 | if (!prevVideo.paused) prevVideo.pause(); 43 | if (!isSafari) curVideo.play(); 44 | 45 | if (swiper.activeIndex === slides.length - 1) fetchNext?.(); 46 | }, 47 | [fetchNext, slides.length] 48 | ); 49 | 50 | return ( 51 | 57 | {slides.map((slide, i) => ( 58 | 64 | {slide} 65 | 66 | ))} 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/pc/pages/video/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useParams, useNavigate } from "react-router-dom"; 3 | 4 | import "./video.scss"; 5 | import PageWithSidebar from "../../components/page-with-sidebar"; 6 | import VideoCard from "../../components/video-card"; 7 | import playOnScroll from "../../components/play-on-scroll"; 8 | import { VideoData } from "../../../common/types"; 9 | import { getVideo } from "../../../common/api/video"; 10 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 11 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 12 | import LoadingSpinner from "../../../common/components/loading-spinner"; 13 | 14 | export default function Video() { 15 | const [videoData, setVideoData] = useState(null); 16 | const videoId = useParams().videoId; 17 | const dispatch = useAppDispatch(); 18 | const navigate = useNavigate(); 19 | const username = useAppSelector(state => state.auth.username); 20 | 21 | useEffect(() => { 22 | async function fetchVideo() { 23 | try { 24 | if (!videoId) throw new Error("Invalid URL"); 25 | const res = await getVideo(videoId, username); 26 | delete res.data.success; 27 | setVideoData(res.data); 28 | } catch (err: any) { 29 | dispatch( 30 | notificationActions.showNotification({ 31 | type: "error", 32 | message: err.message 33 | }) 34 | ); 35 | navigate("/", { replace: true }); 36 | } 37 | } 38 | fetchVideo(); 39 | }, [videoId, dispatch, navigate, username]); 40 | 41 | useEffect(() => { 42 | if (!videoData) return; 43 | return playOnScroll("app-video-card"); 44 | }, [videoData]); 45 | 46 | return ( 47 | 48 |
49 | {!videoData ? : } 50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/common/components/loading-spinner/spinner.module.scss: -------------------------------------------------------------------------------- 1 | $circle-red: #fe2c55; 2 | $circle-blue: #3af2ff; 3 | $initial-transform: translateX(-50%); 4 | $small-scale: 0.7; 5 | $big-scale: 1.3; 6 | $animation-time: 1.5s; 7 | 8 | .spinner-wrapper { 9 | --circle-size: 25px; // use this variable to override spinner size 10 | --wrapper-padding: 25px; // use this variable to override padding 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | padding: var(--wrapper-padding); 15 | } 16 | 17 | .spinner-container { 18 | width: var(--circle-size); 19 | height: var(--circle-size); 20 | position: relative; 21 | z-index: 5; 22 | } 23 | 24 | .spinner-circle { 25 | position: absolute; 26 | display: block; 27 | width: 50%; 28 | padding: 50%; 29 | border-radius: 50%; 30 | mix-blend-mode: darken; 31 | } 32 | 33 | .spinner-circle-red { 34 | background: $circle-red; 35 | animation: spinner-animation-red $animation-time ease-in-out infinite; 36 | } 37 | 38 | .spinner-circle-blue { 39 | background: $circle-blue; 40 | animation: spinner-animation-blue $animation-time ease-in-out infinite; 41 | } 42 | 43 | @keyframes spinner-animation-red { 44 | 0% { 45 | transform: $initial-transform scale(1); 46 | left: 0; 47 | } 48 | 25% { 49 | transform: $initial-transform scale($big-scale); 50 | left: 50%; 51 | } 52 | 50% { 53 | transform: $initial-transform scale(1); 54 | left: 100%; 55 | } 56 | 75% { 57 | transform: $initial-transform scale($small-scale); 58 | left: 50%; 59 | } 60 | 100% { 61 | transform: $initial-transform scale(1); 62 | left: 0; 63 | } 64 | } 65 | @keyframes spinner-animation-blue { 66 | 0% { 67 | transform: $initial-transform scale(1); 68 | left: 100%; 69 | } 70 | 25% { 71 | transform: $initial-transform scale($small-scale); 72 | left: 50%; 73 | } 74 | 50% { 75 | transform: $initial-transform scale(1); 76 | left: 0; 77 | } 78 | 75% { 79 | transform: $initial-transform scale($big-scale); 80 | left: 50%; 81 | } 82 | 100% { 83 | transform: $initial-transform scale(1); 84 | left: 100%; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/mobile/components/drawer/drawer.scss: -------------------------------------------------------------------------------- 1 | $transition: 0.3s ease; 2 | 3 | .drawer-backdrop { 4 | z-index: 5; 5 | opacity: 0; 6 | transition: opacity $transition; 7 | 8 | &.show { 9 | opacity: 1; 10 | } 11 | 12 | &.hide { 13 | opacity: 0; 14 | } 15 | } 16 | 17 | .app-drawer { 18 | position: fixed; 19 | top: 0; 20 | left: 0; 21 | bottom: 0; 22 | z-index: 6; 23 | width: 285px; 24 | height: calc(100vh - var(--navbar-height)); 25 | background: var(--clr-background); 26 | color: var(--clr-text); 27 | pointer-events: auto; 28 | overflow: auto; 29 | transform: translateX(-100%); 30 | transition: transform $transition; 31 | 32 | &.reveal { 33 | transform: translateX(0); 34 | } 35 | 36 | &.hide { 37 | transform: translateX(-100%); 38 | } 39 | 40 | .drawer-header { 41 | display: flex; 42 | justify-content: center; 43 | padding: 12px; 44 | margin-bottom: 12px; 45 | 46 | .image-container { 47 | height: 50px; 48 | width: 171px; 49 | 50 | img { 51 | filter: var(--logo-filter, none); 52 | } 53 | } 54 | } 55 | 56 | .accounts { 57 | text-align: left; 58 | margin: 0 16px; 59 | padding-bottom: 8px; 60 | border-bottom: 2px solid var(--clr-border); 61 | 62 | h5 { 63 | font-size: 0.83rem; 64 | color: rgba(var(--clr-secondary-values), 0.7); 65 | margin-bottom: 8px; 66 | } 67 | } 68 | 69 | .following { 70 | margin-top: 16px; 71 | border: none; 72 | } 73 | 74 | .account-box { 75 | display: flex; 76 | align-items: center; 77 | gap: 8px; 78 | height: 48px; 79 | 80 | .rounded-photo { 81 | width: 32px; 82 | height: 32px; 83 | flex-shrink: 0; 84 | } 85 | 86 | h4 { 87 | font-size: 0.9rem; 88 | font-weight: 600; 89 | white-space: nowrap; 90 | text-overflow: ellipsis; 91 | overflow: hidden; 92 | } 93 | } 94 | 95 | .no-following { 96 | font-size: 0.75rem; 97 | color: rgba(var(--clr-secondary-values), 0.6); 98 | } 99 | 100 | .spinner { 101 | --circle-size: 16px; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/mobile/components/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { NavLink, Link } from "react-router-dom"; 3 | 4 | import "./navbar.scss"; 5 | import { joinClasses } from "../../../common/utils"; 6 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 7 | import { fetchNewNotifs } from "../../store/slices/navbar-slice"; 8 | 9 | export default function Navbar() { 10 | const dispatch = useAppDispatch(); 11 | const { 12 | username, 13 | token, 14 | isAuthenticated: isAuthed 15 | } = useAppSelector(state => state.auth); 16 | const hasNewNotifs = useAppSelector( 17 | state => state.mobile.navbar.hasNewNotifs 18 | ); 19 | 20 | useEffect(() => { 21 | if (!isAuthed) return; 22 | dispatch(fetchNewNotifs({ username: username!, token: token! })); 23 | }, [dispatch, isAuthed, username, token]); 24 | 25 | return ( 26 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/pc/pages/edit-profile/edit-profile.scss: -------------------------------------------------------------------------------- 1 | .edit-profile-container { 2 | .card { 3 | background: var(--clr-bg-elevation-1, var(--clr-background)); 4 | box-shadow: var(--shadows, var(--clr-shadow) 0px 2px 8px); 5 | border-radius: 8px; 6 | margin-top: 16px; 7 | margin-bottom: 24px; 8 | padding: 24px 56px 36px; 9 | 10 | header { 11 | h1 { 12 | font-size: 1.4rem; 13 | font-weight: 600; 14 | } 15 | 16 | h4 { 17 | margin-top: 4px; 18 | color: rgba(var(--clr-secondary-values), 0.6); 19 | } 20 | } 21 | 22 | .card-body { 23 | margin-top: 20px; 24 | 25 | .rounded-photo { 26 | width: 160px; 27 | height: 160px; 28 | } 29 | 30 | form { 31 | display: flex; 32 | flex-direction: column; 33 | gap: 28px; 34 | 35 | .form-group { 36 | display: flex; 37 | align-items: center; 38 | 39 | .data { 40 | display: flex; 41 | align-items: center; 42 | margin-left: auto; 43 | max-width: 30%; 44 | 45 | p { 46 | font-weight: 600; 47 | } 48 | } 49 | 50 | .edit { 51 | color: var(--clr-primary); 52 | margin-left: 8px; 53 | 54 | &:hover { 55 | text-decoration: underline; 56 | } 57 | } 58 | 59 | .passwords { 60 | flex-direction: column; 61 | gap: 16px; 62 | } 63 | } 64 | 65 | .pfp { 66 | flex-direction: column; 67 | gap: 8px; 68 | margin: 24px; 69 | 70 | input { 71 | display: none; 72 | } 73 | 74 | img { 75 | transition: opacity 0.15s ease-out; 76 | 77 | &:hover { 78 | opacity: 0.5; 79 | } 80 | } 81 | } 82 | 83 | .buttons { 84 | display: flex; 85 | gap: 12px; 86 | margin-top: 20px; 87 | align-self: center; 88 | 89 | button { 90 | width: 120px; 91 | } 92 | 93 | .info-button { 94 | border: none; 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | .save-spinner { 102 | --circle-size: 12px; 103 | --wrapper-padding: 0; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/pc/store/slices/sidebar-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; 2 | 3 | import { getCustom } from "../../../common/api/user"; 4 | import { getSuggested } from "../../../common/api/feed"; 5 | import { UserData } from "../../../common/types"; 6 | 7 | interface InitState { 8 | following: UserData[] | null; 9 | suggested: UserData[] | null; 10 | error: string | null; 11 | } 12 | 13 | const initialState: InitState = { 14 | following: null, 15 | suggested: null, 16 | error: null 17 | }; 18 | 19 | const fetchFollowing = createAsyncThunk( 20 | "sidebar/following", 21 | async (username: string) => { 22 | const res = await getCustom({ following: "list" }, username); 23 | return res.data.following.slice(0, 5); 24 | } 25 | ); 26 | 27 | const fetchSuggested = createAsyncThunk( 28 | "sidebar/suggested", 29 | async (limit?: number) => { 30 | const res = await getSuggested(limit); 31 | return res.data.users; 32 | } 33 | ); 34 | 35 | const sidebarSlice = createSlice({ 36 | name: "sidebar", 37 | initialState, 38 | reducers: {}, 39 | extraReducers: builder => { 40 | builder.addCase(fetchFollowing.pending, state => { 41 | state.following = null; 42 | state.error = null; 43 | }); 44 | 45 | builder.addCase(fetchFollowing.rejected, (state, action) => { 46 | state.error = action.error.message!; 47 | state.following = []; 48 | }); 49 | 50 | builder.addCase(fetchFollowing.fulfilled, (state, action) => { 51 | state.error = null; 52 | state.following = action.payload; 53 | }); 54 | 55 | builder.addCase(fetchSuggested.pending, state => { 56 | state.suggested = null; 57 | state.error = null; 58 | }); 59 | 60 | builder.addCase(fetchSuggested.rejected, (state, action) => { 61 | state.error = action.error.message!; 62 | state.suggested = []; 63 | }); 64 | 65 | builder.addCase(fetchSuggested.fulfilled, (state, action) => { 66 | state.error = null; 67 | state.suggested = action.payload; 68 | }); 69 | } 70 | }); 71 | 72 | export default sidebarSlice.reducer; 73 | export const sidebarActions = sidebarSlice.actions; 74 | export { fetchFollowing, fetchSuggested }; 75 | -------------------------------------------------------------------------------- /src/pc/components/video-card/video-card.scss: -------------------------------------------------------------------------------- 1 | @import "../../index.scss"; 2 | 3 | $profile-pic-size: 56px; 4 | $video-player-height: calc(450px + (100vw - 768px) / 1152 * 100); 5 | 6 | .app-video-card { 7 | display: flex; 8 | gap: 16px; 9 | padding: 32px 8px 32px 0; 10 | border-bottom: 2px solid var(--clr-border); 11 | 12 | .profile-pic { 13 | display: block; 14 | position: relative; 15 | align-self: flex-start; 16 | 17 | .rounded-photo { 18 | width: $profile-pic-size; 19 | height: $profile-pic-size; 20 | } 21 | } 22 | 23 | .card-content { 24 | display: flex; 25 | flex-direction: column; 26 | width: 100%; 27 | 28 | header { 29 | display: flex; 30 | gap: 6px; 31 | height: 24px; 32 | 33 | .username { 34 | font-weight: 700; 35 | cursor: pointer; 36 | 37 | &:hover { 38 | text-decoration: underline; 39 | } 40 | } 41 | 42 | h5 { 43 | font-size: 0.75rem; 44 | } 45 | 46 | .uploader { 47 | display: flex; 48 | align-items: baseline; 49 | gap: 4px; 50 | } 51 | } 52 | 53 | .caption { 54 | font-size: 0.9rem; 55 | } 56 | 57 | .tags { 58 | margin-top: 2px; 59 | } 60 | 61 | .music { 62 | display: flex; 63 | align-items: center; 64 | gap: 4px; 65 | font-size: 0.85rem; 66 | font-weight: 500; 67 | margin-top: 4px; 68 | margin-bottom: 12px; 69 | } 70 | 71 | .card-video { 72 | display: flex; 73 | gap: 16px; 74 | 75 | .video-container { 76 | position: relative; 77 | border-radius: 8px; 78 | overflow: hidden; 79 | height: $video-player-height; 80 | 81 | video { 82 | width: 100%; 83 | height: 100%; 84 | cursor: pointer; 85 | object-fit: contain; 86 | background-color: #000; 87 | } 88 | } 89 | 90 | .action-buttons { 91 | display: flex; 92 | align-self: flex-end; 93 | flex-direction: column; 94 | gap: 8px; 95 | 96 | .action-btn-container { 97 | flex-direction: column; 98 | } 99 | } 100 | } 101 | } 102 | 103 | .follow-btn { 104 | margin-left: auto; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/pc/components/follow-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect, MouseEvent } from "react"; 2 | 3 | import { followUser } from "../../../common/api/user"; 4 | import { useAppSelector, useAppDispatch } from "../../../common/store"; 5 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 6 | import { fetchFollowing } from "../../store/slices/sidebar-slice"; 7 | 8 | interface Props { 9 | isFollowing?: boolean; 10 | toFollow: string; 11 | onClick?: (followed: boolean) => any; 12 | followingClassName?: string; 13 | followClassName?: string; 14 | hideUnfollow?: boolean; 15 | } 16 | 17 | export default function FollowButton(props: Props) { 18 | const { username: loggedInAs, token } = useAppSelector(state => state.auth); 19 | const [isFollowing, setIsFollowing] = useState(props.isFollowing); 20 | const dispatch = useAppDispatch(); 21 | const { toFollow, onClick } = props; 22 | 23 | useEffect(() => { 24 | setIsFollowing(props.isFollowing); 25 | }, [props.isFollowing]); 26 | 27 | const follow = useCallback( 28 | async (e: MouseEvent) => { 29 | try { 30 | if (!loggedInAs) throw new Error("Log in to follow " + toFollow); 31 | const res = await followUser(toFollow, loggedInAs, token!); 32 | await dispatch(fetchFollowing(loggedInAs)).unwrap(); 33 | setIsFollowing(res.data.followed); 34 | if (onClick) onClick(res.data.followed); 35 | dispatch( 36 | notificationActions.showNotification({ 37 | type: "success", 38 | message: res.data.followed 39 | ? "You started following " + toFollow 40 | : "You unfollowed " + toFollow 41 | }) 42 | ); 43 | } catch (err: any) { 44 | dispatch( 45 | notificationActions.showNotification({ 46 | type: "error", 47 | message: err.message 48 | }) 49 | ); 50 | } 51 | e.stopPropagation(); 52 | e.preventDefault(); 53 | }, 54 | [toFollow, loggedInAs, onClick, dispatch, token] 55 | ); 56 | 57 | return isFollowing ? ( 58 | !props.hideUnfollow ? ( 59 | 62 | ) : null 63 | ) : ( 64 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/pc/components/video-modal/CommentForm.tsx: -------------------------------------------------------------------------------- 1 | import { useFormik } from "formik"; 2 | import * as yup from "yup"; 3 | 4 | import Input from "../../../common/components/input-field"; 5 | import { useAppSelector, useAppDispatch } from "../../../common/store"; 6 | import constants from "../../../common/constants"; 7 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 8 | import { postComment } from "../../../common/api/video"; 9 | import { CommentData } from "../../../common/types"; 10 | 11 | const validationSchema = yup.object().shape({ 12 | comment: yup 13 | .string() 14 | .trim() 15 | .required("") 16 | .max( 17 | constants.commentMaxLen, 18 | `At most ${constants.commentMaxLen} characters` 19 | ) 20 | }); 21 | 22 | interface Props { 23 | fetchComments: () => Promise; 24 | videoId: string; 25 | setComments: React.Dispatch>; 26 | fetchCommentsNum: () => Promise; 27 | } 28 | 29 | export default function AddComment({ 30 | videoId, 31 | fetchComments, 32 | fetchCommentsNum, 33 | setComments 34 | }: Props) { 35 | const { username, token } = useAppSelector(state => state.auth); 36 | const dispatch = useAppDispatch(); 37 | const formik = useFormik({ 38 | initialValues: { comment: "" }, 39 | validationSchema, 40 | onSubmit: async ({ comment }) => { 41 | try { 42 | await postComment(username!, comment, videoId, token!); 43 | dispatch( 44 | notificationActions.showNotification({ 45 | type: "success", 46 | message: "Comment posted" 47 | }) 48 | ); 49 | setComments(null); 50 | fetchComments(); 51 | formik.setFieldValue("comment", ""); 52 | fetchCommentsNum(); 53 | } catch (err: any) { 54 | dispatch( 55 | notificationActions.showNotification({ 56 | type: "error", 57 | message: err.message 58 | }) 59 | ); 60 | } 61 | } 62 | }); 63 | 64 | return ( 65 |
66 | 78 | 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | export interface ComponentProps { 2 | className?: string; 3 | } 4 | 5 | interface CommonData { 6 | createdAt?: Date; 7 | } 8 | 9 | export interface UserData extends CommonData { 10 | username?: string; 11 | name?: string; 12 | email?: string; 13 | description?: string; 14 | profilePhoto?: string; 15 | following?: number | UserData[]; 16 | followers?: number | UserData[]; 17 | totalLikes?: number; 18 | videos?: VideoData[] | string[]; // either uploaded or liked, can't be both 19 | isFollowing?: boolean; 20 | } 21 | 22 | export interface VideoData extends CommonData { 23 | _id?: string; 24 | videoId?: string; 25 | uploader?: UserData; 26 | caption?: string; 27 | music?: string; 28 | video?: string; 29 | tags?: string[]; 30 | likes?: number; 31 | comments?: number | CommentData[]; 32 | shares?: number; 33 | views?: number; 34 | hasLiked?: boolean; 35 | isFollowing?: boolean; 36 | } 37 | 38 | export interface CommentData extends CommonData { 39 | _id?: string; 40 | commentId?: string; 41 | replyId?: string; 42 | postedBy?: UserData; 43 | comment?: string; 44 | likes?: number; 45 | replies?: number | CommentData[]; 46 | hasLiked?: boolean; 47 | } 48 | 49 | export interface LoginData { 50 | username: string; 51 | password: string; 52 | } 53 | 54 | export interface SignupData { 55 | email: string; 56 | username: string; 57 | name: string; 58 | password: string; 59 | confpass: string; 60 | } 61 | 62 | export interface VideoQuery { 63 | username?: string; 64 | uploader?: "1"; 65 | caption?: "1"; 66 | music?: "1"; 67 | tags?: "1"; 68 | shares?: "1"; 69 | views?: "1"; 70 | createdAt?: "1"; 71 | likes?: "1"; 72 | comments?: "num" | "list"; 73 | } 74 | 75 | export interface UserQuery { 76 | name?: "1"; 77 | email?: "1"; 78 | description?: "1"; 79 | totalLikes?: "1"; 80 | createdAt?: "1"; 81 | notifications?: "1"; 82 | following?: "list" | "num"; 83 | followers?: "list" | "num"; 84 | videos?: "uploaded" | "liked"; 85 | loggedInAs?: string; 86 | } 87 | 88 | export interface UserNotification { 89 | _id?: string; 90 | notificationId?: string; 91 | type: "likedVideo" | "followed" | "commented" | "replied"; 92 | message: string; 93 | refId: UserData | VideoData | string; 94 | by: UserData; 95 | meta?: any; 96 | read: boolean; 97 | createdAt: Date; 98 | } 99 | 100 | export interface VideoDynamics { 101 | hasLiked: boolean; 102 | likesNum: number; 103 | commentsNum: number; 104 | isFollowing: boolean | undefined; 105 | } 106 | -------------------------------------------------------------------------------- /src/pc/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, Suspense, lazy, useState } from "react"; 2 | import { Routes, Route, useLocation, Navigate } from "react-router-dom"; 3 | 4 | import "./index.scss"; 5 | import { useAppSelector } from "../common/store"; 6 | import FullscreenSpinner from "../common/components/fullscreen-spinner"; 7 | import Header from "./components/header"; 8 | import Notification from "../common/components/notification"; 9 | import AuthModal from "../common/components/auth-modal"; 10 | import PrivateRoute from "../common/components/private-route"; 11 | import LegalNotice from "../common/components/legal-notice"; 12 | const Home = lazy(() => import("./pages/home")); 13 | const Following = lazy(() => import("./pages/following")); 14 | const Profile = lazy(() => import("./pages/profile")); 15 | const Video = lazy(() => import("./pages/video")); 16 | const Upload = lazy(() => import("./pages/upload")); 17 | const EditProfile = lazy(() => import("./pages/edit-profile")); 18 | const Search = lazy(() => import("./pages/search")); 19 | 20 | export default function PCLayout() { 21 | const { pathname } = useLocation(); 22 | const { notification, authModal } = useAppSelector(state => state); 23 | const [showNotice, setShowNotice] = useState(false); 24 | 25 | useEffect(() => { 26 | window.scrollTo(0, 0); 27 | }, [pathname]); 28 | 29 | useEffect(() => { 30 | let hasSeenNotice = localStorage.getItem("hasSeenNotice"); 31 | if (hasSeenNotice) return; 32 | setShowNotice(true); 33 | localStorage.setItem("hasSeenNotice", "true"); 34 | }, []); 35 | 36 | return ( 37 |
38 |
39 | {showNotice && } 40 | {authModal.show && } 41 | {notification.show && ( 42 | 46 | )} 47 | }> 48 | 49 | } /> 50 | } /> 51 | } /> 52 | } /> 53 | } /> 54 | } /> 55 | } /> 56 | }> 57 | } /> 58 | } /> 59 | 60 | 61 | 62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/pc/components/profile-buttons/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react"; 2 | 3 | import "./profile-buttons.scss"; 4 | 5 | interface Props { 6 | setVideosType: React.Dispatch>; 7 | fetchLikedVids: () => Promise; 8 | username: string; 9 | } 10 | 11 | export default function ProfileButtons({ 12 | setVideosType, 13 | fetchLikedVids, 14 | username 15 | }: Props) { 16 | const buttonsRef = useRef(null); 17 | 18 | useEffect(() => { 19 | const container = buttonsRef.current!; 20 | const buttons = container.querySelectorAll("button"); 21 | const bar = container.querySelector("span")!; 22 | let prevPosition = bar.style.left; 23 | 24 | function handleButton(e: any) { 25 | if (e.target.classList.contains("active")) return; 26 | if (e.type === "mouseover") { 27 | bar.style.left = `${50 * +e.target.dataset.position}%`; 28 | } else if (e.type === "mouseout") { 29 | bar.style.left = prevPosition; 30 | } else { 31 | container.querySelector("button.active")!.className = ""; 32 | e.target.className = "active"; 33 | prevPosition = `${50 * +e.target.dataset.position}%`; 34 | } 35 | } 36 | 37 | buttons.forEach(button => { 38 | button.addEventListener("mouseover", handleButton); 39 | button.addEventListener("mouseout", handleButton); 40 | button.addEventListener("click", handleButton); 41 | }); 42 | 43 | return () => 44 | buttons.forEach(button => { 45 | button.removeEventListener("mouseover", handleButton); 46 | button.removeEventListener("mouseout", handleButton); 47 | button.removeEventListener("click", handleButton); 48 | }); 49 | }, []); 50 | 51 | useEffect(() => { 52 | const container = buttonsRef.current!; 53 | const bar = container.querySelector("span")!; 54 | 55 | bar.style.left = "0%"; 56 | container.querySelector("button.active")!.className = ""; 57 | container.querySelector("button#uploaded")!.className = "active"; 58 | }, [username]); 59 | 60 | return ( 61 |
62 |
63 | 71 | 81 |
82 |
83 | 84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/mobile/pages/edit-profile/edit-profile.scss: -------------------------------------------------------------------------------- 1 | $header-height: 44px; 2 | $pfp-size: 120px; 3 | 4 | .edit-profile { 5 | background: var(--clr-background); 6 | color: var(--clr-text); 7 | overflow: auto; 8 | padding-bottom: 12px; 9 | 10 | header { 11 | display: flex; 12 | height: $header-height; 13 | line-height: $header-height; 14 | font-size: 17px; 15 | font-weight: 600; 16 | padding: 0 18px; 17 | border-bottom: 2px solid var(--clr-border); 18 | text-align: center; 19 | margin-bottom: 20px; 20 | 21 | > div { 22 | width: 24px; 23 | } 24 | 25 | h4 { 26 | flex: 1 1 0%; 27 | padding: 0 12px; 28 | } 29 | 30 | .fa-save { 31 | color: var(--clr-primary); 32 | } 33 | } 34 | 35 | .content { 36 | padding: 0 20px; 37 | } 38 | 39 | section { 40 | display: flex; 41 | flex-direction: column; 42 | padding-bottom: 12px; 43 | border-bottom: 2px solid var(--clr-border); 44 | margin-bottom: 20px; 45 | 46 | &:last-of-type { 47 | border: none; 48 | } 49 | 50 | h4 { 51 | font-size: 0.85rem; 52 | font-weight: 600; 53 | color: rgba(var(--clr-secondary-values), 0.5); 54 | margin-bottom: 8px; 55 | } 56 | 57 | .section-content { 58 | font-size: 0.85rem; 59 | } 60 | } 61 | 62 | .rounded-photo { 63 | width: $pfp-size; 64 | height: $pfp-size; 65 | } 66 | 67 | .pfp { 68 | display: flex; 69 | flex-direction: column; 70 | gap: 8px; 71 | } 72 | 73 | .form-group { 74 | display: flex; 75 | align-items: center; 76 | justify-content: space-between; 77 | padding: 12px 0; 78 | 79 | label { 80 | color: rgba(var(--clr-secondary-values), 0.7); 81 | } 82 | 83 | p { 84 | display: flex; 85 | align-items: center; 86 | justify-content: center; 87 | 88 | i { 89 | font-size: 1.2rem; 90 | color: rgba(var(--clr-secondary-values), 0.75); 91 | } 92 | } 93 | 94 | .data { 95 | display: flex; 96 | align-items: center; 97 | gap: 6px; 98 | flex-shrink: 1; 99 | max-width: 60%; 100 | } 101 | 102 | .passwords { 103 | flex-direction: column; 104 | } 105 | 106 | span, 107 | .edit { 108 | color: var(--clr-primary); 109 | } 110 | 111 | .error-container { 112 | font-size: 0.7rem; 113 | } 114 | 115 | #pfp-input { 116 | display: none; 117 | } 118 | } 119 | 120 | .made-with { 121 | font-size: 0.77rem; 122 | text-align: center; 123 | color: rgba(var(--clr-secondary-values), 0.6); 124 | 125 | a { 126 | text-decoration: underline; 127 | } 128 | } 129 | 130 | .spinner { 131 | --wrapper-padding: 70% 0 0 0; 132 | --circle-size: 12px; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/pc/pages/profile/profile.scss: -------------------------------------------------------------------------------- 1 | @import "../../index.scss"; 2 | 3 | $container-width: 596px; 4 | $profile-pic-size: 116px; 5 | $suggested-pfp-size: 32px; 6 | 7 | .profile-page-container { 8 | display: flex; 9 | justify-content: space-between; 10 | gap: 36px; 11 | 12 | .profile-container { 13 | display: flex; 14 | flex-direction: column; 15 | width: $container-width; 16 | padding: 8px 0 36px; 17 | 18 | .profile-header { 19 | display: flex; 20 | gap: 16px; 21 | margin: 28px 0; 22 | 23 | .rounded-photo { 24 | width: $profile-pic-size; 25 | height: $profile-pic-size; 26 | } 27 | 28 | h1 { 29 | font-weight: 700; 30 | font-size: 1.7rem; 31 | } 32 | 33 | h4 { 34 | font-weight: 500; 35 | font-size: 1rem; 36 | } 37 | 38 | button { 39 | padding: 8px; 40 | margin-top: 12px; 41 | width: 100%; 42 | } 43 | } 44 | 45 | .user-details { 46 | display: flex; 47 | flex-direction: column; 48 | gap: 12px; 49 | color: rgba(var(--clr-secondary-values), 0.8); 50 | 51 | .counts { 52 | display: flex; 53 | gap: 20px; 54 | 55 | p { 56 | display: flex; 57 | align-items: baseline; 58 | gap: 5px; 59 | font-weight: 300; 60 | font-size: 0.95rem; 61 | 62 | strong { 63 | font-weight: 600; 64 | font-size: 1rem; 65 | } 66 | } 67 | } 68 | 69 | .description { 70 | font-size: 0.9rem; 71 | } 72 | } 73 | 74 | .suggested { 75 | margin: 28px 0; 76 | 77 | h5 { 78 | display: flex; 79 | align-items: center; 80 | justify-content: space-between; 81 | color: rgba(var(--clr-secondary-values), 0.75); 82 | font-weight: 600; 83 | font-size: 0.75rem; 84 | } 85 | 86 | .account-buttons { 87 | display: flex; 88 | gap: 24px; 89 | margin-top: 16px; 90 | } 91 | 92 | .acc-btn { 93 | display: flex; 94 | align-items: center; 95 | gap: 8px; 96 | padding: 2px 12px 1.5px 2px; 97 | border: 2px solid var(--clr-border); 98 | border-radius: 20px; 99 | 100 | .rounded-photo { 101 | width: $suggested-pfp-size; 102 | height: $suggested-pfp-size; 103 | } 104 | 105 | h4 { 106 | font-weight: 500; 107 | font-size: 0.88rem; 108 | } 109 | } 110 | } 111 | 112 | .profile-cards-container { 113 | display: grid; 114 | grid-template-columns: repeat(3, 1fr); 115 | gap: 4px; 116 | margin-top: 2px; 117 | 118 | &.ungrid { 119 | display: flex; 120 | align-items: center; 121 | justify-content: center; 122 | margin-top: 24px; 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/pc/components/video-modal/ReplyForm.tsx: -------------------------------------------------------------------------------- 1 | import { useFormik } from "formik"; 2 | import * as yup from "yup"; 3 | 4 | import Input from "../../../common/components/input-field"; 5 | import constants from "../../../common/constants"; 6 | import { reply } from "../../../common/api/video"; 7 | import { useAppSelector, useAppDispatch } from "../../../common/store"; 8 | import { CommentData } from "../../../common/types"; 9 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 10 | 11 | const validationSchema = yup.object().shape({ 12 | comment: yup 13 | .string() 14 | .required("") 15 | .max( 16 | constants.commentMaxLen, 17 | `At most ${constants.commentMaxLen} characters` 18 | ) 19 | }); 20 | 21 | interface Props { 22 | commentId: string; 23 | videoId: string; 24 | setReplies: React.Dispatch>; 25 | setShowReplies: React.Dispatch>; 26 | hideReplyInput: () => void; 27 | fetchReplies: () => Promise; 28 | setTotalReplies: React.Dispatch>; 29 | } 30 | 31 | export default function ReplyForm(props: Props) { 32 | const { username, token } = useAppSelector(state => state.auth); 33 | const dispatch = useAppDispatch(); 34 | 35 | const formik = useFormik({ 36 | initialValues: { 37 | comment: "" 38 | }, 39 | validationSchema, 40 | onSubmit: async ({ comment }) => { 41 | try { 42 | await reply(comment, props.commentId, props.videoId, username!, token!); 43 | dispatch( 44 | notificationActions.showNotification({ 45 | type: "success", 46 | message: "Reply added" 47 | }) 48 | ); 49 | props.setReplies(null); 50 | props.setReplies(await props.fetchReplies()); 51 | props.setTotalReplies(prev => prev + 1); 52 | formik.setFieldValue("comment", ""); 53 | props.hideReplyInput(); 54 | props.setShowReplies(true); 55 | } catch (err: any) { 56 | dispatch( 57 | notificationActions.showNotification({ 58 | type: "error", 59 | message: err.message 60 | }) 61 | ); 62 | } 63 | } 64 | }); 65 | 66 | return ( 67 |
68 | 79 | 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/mobile/components/searched-video/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import classes from "./searched-video.module.scss"; 5 | import { VideoData } from "../../../common/types"; 6 | import constants from "../../../common/constants"; 7 | import { joinClasses } from "../../../common/utils"; 8 | 9 | export default function SearchedVideo(props: VideoData) { 10 | const videoRef = useRef(null); 11 | 12 | useEffect(() => { 13 | if (!videoRef.current) return; 14 | const vid = videoRef.current; 15 | let cancelClick: boolean, clickTimeout: NodeJS.Timeout, isPlaying: boolean; 16 | 17 | function handleClick(e: Event) { 18 | if (cancelClick) e.stopPropagation(); 19 | } 20 | function handleTouchStart() { 21 | clickTimeout = setTimeout(() => { 22 | vid.play(); 23 | isPlaying = true; 24 | cancelClick = true; 25 | }, 300); 26 | } 27 | function handleTouchEnd() { 28 | clearTimeout(clickTimeout); 29 | if (isPlaying) { 30 | vid.pause(); 31 | isPlaying = false; 32 | } else cancelClick = false; 33 | } 34 | function noMenu(e: Event) { 35 | e.preventDefault(); 36 | } 37 | 38 | vid.addEventListener("touchstart", handleTouchStart); 39 | vid.addEventListener("touchend", handleTouchEnd); 40 | vid.addEventListener("click", handleClick); 41 | vid.addEventListener("contextmenu", noMenu); 42 | 43 | return () => { 44 | vid.removeEventListener("touchstart", handleTouchStart); 45 | vid.removeEventListener("touchend", handleTouchEnd); 46 | vid.removeEventListener("click", handleClick); 47 | vid.removeEventListener("contextmenu", noMenu); 48 | }; 49 | }, []); 50 | 51 | return ( 52 | 53 |
54 |
61 |
62 |

{props.caption}

63 |

64 | {props.tags!.map((tag, i) => ( 65 | #{tag} 66 | ))} 67 |

68 |
69 |
70 |
71 | {props.uploader!.name} 75 |
76 | {props.uploader!.username} 77 |
78 | 79 | {props.views} 80 | 81 |
82 |
83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/mobile/components/profile-video/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | import classes from "./profile-video.module.scss"; 5 | import LoadingSpinner from "../../../common/components/loading-spinner"; 6 | import constants from "../../../common/constants"; 7 | 8 | interface Props { 9 | video: string; 10 | } 11 | 12 | export default function ProfileVideo({ video }: Props) { 13 | const navigate = useNavigate(); 14 | const [showSpinner, setShowSpinner] = useState(false); 15 | const containerRef = useRef(null); 16 | const videoRef = useRef(null); 17 | 18 | useEffect(() => { 19 | if (!containerRef.current || !videoRef.current) return; 20 | const container = containerRef.current; 21 | const vid = videoRef.current; 22 | let playTimeout: NodeJS.Timeout, 23 | isPlaying = false, 24 | cancelClick = false; 25 | 26 | function handleClick() { 27 | if (cancelClick) return; 28 | navigate("/video/" + video); 29 | } 30 | function handleTouchStart() { 31 | playTimeout = setTimeout(() => { 32 | vid.play(); 33 | isPlaying = true; 34 | cancelClick = true; 35 | }, 300); 36 | } 37 | function handleTouchEnd() { 38 | clearTimeout(playTimeout); 39 | if (isPlaying) { 40 | vid.pause(); 41 | isPlaying = false; 42 | } else cancelClick = false; 43 | } 44 | function toggleSpinnerOn() { 45 | setShowSpinner(true); 46 | } 47 | function toggleSpinnerOff() { 48 | setShowSpinner(false); 49 | } 50 | function disableContextMenu(e: Event) { 51 | e.preventDefault(); 52 | } 53 | 54 | container.addEventListener("click", handleClick); 55 | container.addEventListener("touchstart", handleTouchStart); 56 | container.addEventListener("touchend", handleTouchEnd); 57 | vid.addEventListener("waiting", toggleSpinnerOn); 58 | vid.addEventListener("playing", toggleSpinnerOff); 59 | vid.addEventListener("contextmenu", disableContextMenu); 60 | 61 | return () => { 62 | container.removeEventListener("click", handleClick); 63 | container.removeEventListener("touchstart", handleTouchStart); 64 | container.removeEventListener("touchend", handleTouchEnd); 65 | vid.removeEventListener("waiting", toggleSpinnerOn); 66 | vid.removeEventListener("playing", toggleSpinnerOff); 67 | vid.removeEventListener("contextmenu", disableContextMenu); 68 | }; 69 | }, [navigate, video]); 70 | 71 | return ( 72 |
73 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/mobile/pages/notifications/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | import PageWithNavbar from "../../components/page-with-navbar"; 4 | import "./notifications.scss"; 5 | import UnauthedPage from "../../components/unauthed-page"; 6 | import NotificationBox from "../../components/notification-box"; 7 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 8 | import { errorNotification } from "../../helpers/error-notification"; 9 | import { UserNotification } from "../../../common/types"; 10 | import { getCustom, readAllNotifs } from "../../../common/api/user"; 11 | import LoadingSpinner from "../../../common/components/loading-spinner"; 12 | import { navbarActions } from "../../store/slices/navbar-slice"; 13 | 14 | let hadNewNotifs: boolean; 15 | 16 | export default function Notifications() { 17 | const dispatch = useAppDispatch(); 18 | const { 19 | username, 20 | isAuthenticated: isAuthed, 21 | token 22 | } = useAppSelector(state => state.auth); 23 | const hasNewNotifs = useAppSelector( 24 | state => state.mobile.navbar.hasNewNotifs 25 | ); 26 | const [notifs, setNotifs] = useState(null); 27 | 28 | const fetchNotifs = useCallback(() => { 29 | errorNotification( 30 | async () => { 31 | const res = await getCustom({ notifications: "1" }, username!); 32 | setNotifs(res.data.notifications); 33 | }, 34 | dispatch, 35 | () => setNotifs([]), 36 | "Couldn't load notifications:" 37 | ); 38 | }, [dispatch, username]); 39 | 40 | useEffect(() => { 41 | if (!isAuthed) return; 42 | fetchNotifs(); 43 | }, [fetchNotifs, isAuthed]); 44 | 45 | useEffect(() => { 46 | if (!isAuthed) return; 47 | hadNewNotifs = hasNewNotifs; 48 | if (hasNewNotifs) dispatch(navbarActions.hasReadNotifs()); 49 | 50 | return () => { 51 | if (!hadNewNotifs) return; 52 | readAllNotifs(username!, token!).catch(err => 53 | console.error("Couldn't read all notifications", err) 54 | ); 55 | }; 56 | }, [isAuthed, username, token, hasNewNotifs, dispatch]); 57 | 58 | return isAuthed ? ( 59 | 60 |
Notifications
61 |
62 | {!notifs ? ( 63 | 64 | ) : notifs.length === 0 ? ( 65 |
No notifications
66 | ) : ( 67 | notifs.map((notif, i) => ( 68 | 74 | )) 75 | )} 76 |
77 |
78 | ) : ( 79 | } 82 | description="Log in to see your notifications" 83 | /> 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/common/store/slices/auth-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; 2 | 3 | import * as authApi from "../../api/auth"; 4 | import { LoginData, SignupData } from "../../types"; 5 | 6 | interface InitState { 7 | isAuthenticated: boolean; 8 | token: string | null; 9 | username: string | null; 10 | status: "fetching" | "loading" | null; 11 | error: string | null; 12 | } 13 | 14 | const initialState: InitState = { 15 | isAuthenticated: false, 16 | token: null, 17 | username: null, 18 | status: "fetching", 19 | error: null 20 | }; 21 | 22 | export const loginThunk = createAsyncThunk( 23 | "auth/login", 24 | async (payload: LoginData) => { 25 | const res = await authApi.login(payload); 26 | return res.data; 27 | } 28 | ); 29 | 30 | export const signupThunk = createAsyncThunk( 31 | "auth/signup", 32 | async (payload: SignupData) => { 33 | const res = await authApi.signup(payload); 34 | return res.data; 35 | } 36 | ); 37 | 38 | const authSlice = createSlice({ 39 | name: "auth", 40 | initialState, 41 | reducers: { 42 | login(_, action) { 43 | localStorage.setItem( 44 | "userData", 45 | JSON.stringify({ 46 | username: action.payload.username, 47 | token: action.payload.token 48 | }) 49 | ); 50 | // fetch from the store after reload 51 | window.location.reload(); 52 | }, 53 | logout() { 54 | localStorage.removeItem("userData"); 55 | // reload automatically resets all state 56 | window.location.href = "/"; 57 | }, 58 | loginOnLoad(state) { 59 | if (localStorage.getItem("userData")) { 60 | const user = JSON.parse(localStorage.getItem("userData")!); 61 | state.username = user.username; 62 | state.token = user.token; 63 | state.isAuthenticated = true; 64 | } 65 | state.status = null; 66 | } 67 | }, 68 | extraReducers: builder => { 69 | builder.addCase(loginThunk.fulfilled, (state, action) => { 70 | authSlice.caseReducers.login(state, action); 71 | }); 72 | 73 | builder.addCase(loginThunk.pending, state => { 74 | state.error = null; 75 | state.status = "loading"; 76 | }); 77 | 78 | builder.addCase(loginThunk.rejected, (state, action) => { 79 | state.error = action.error.message!; 80 | state.status = null; 81 | }); 82 | 83 | builder.addCase(signupThunk.fulfilled, (state, action) => { 84 | authSlice.caseReducers.login(state, action); 85 | }); 86 | 87 | builder.addCase(signupThunk.pending, state => { 88 | state.error = null; 89 | state.status = "loading"; 90 | }); 91 | 92 | builder.addCase(signupThunk.rejected, (state, action) => { 93 | state.error = action.error.message!; 94 | state.status = null; 95 | }); 96 | } 97 | }); 98 | 99 | export default authSlice.reducer; 100 | export const authActions = authSlice.actions; 101 | -------------------------------------------------------------------------------- /src/pc/components/user-dropdown/DD.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import "./user-dropdown.scss"; 4 | import Dropdown from "../../../common/components/dropdown"; 5 | import LoadingSpinner from "../../../common/components/loading-spinner"; 6 | import FollowButton from "../follow-button"; 7 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 8 | import { UserData } from "../../../common/types"; 9 | import constants from "../../../common/constants"; 10 | import { getShortUser } from "../../../common/api/user"; 11 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 12 | 13 | interface Props { 14 | onMouseOver: () => void; 15 | onMouseOut: () => void; 16 | username: string; 17 | onFollow?: (followed: boolean) => void; 18 | } 19 | 20 | export default function CardDropdown(props: Props) { 21 | const [user, setUser] = useState(null); 22 | const loggedInAs = useAppSelector(state => state.auth.username); 23 | const dispatch = useAppDispatch(); 24 | 25 | useEffect(() => { 26 | async function fetchData() { 27 | try { 28 | const res = await getShortUser(props.username, loggedInAs); 29 | setUser(res.data); 30 | } catch (err: any) { 31 | dispatch( 32 | notificationActions.showNotification({ 33 | type: "error", 34 | message: err.message 35 | }) 36 | ); 37 | } 38 | } 39 | fetchData(); 40 | }, [dispatch, props.username, loggedInAs]); 41 | 42 | return ( 43 | 49 | {!user ? ( 50 | 51 | ) : ( 52 | <> 53 |
54 |
55 | {user.name} 59 |
60 | {loggedInAs !== user.username && ( 61 |
62 | 68 |
69 | )} 70 |
71 |
72 |
73 |

{user.username}

74 |
{user.name}
75 |
76 |
77 |
78 |

79 | {user.followers}  80 | {user.followers! === 1 ? "Follower" : "Followers"} 81 |

82 |

83 | {user.totalLikes}  84 | {user.totalLikes! === 1 ? "Like" : "Likes"} 85 |

86 |
87 |

{user.description}

88 | 89 | )} 90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/pc/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useCallback } from "react"; 2 | 3 | import "./home.scss"; 4 | import PageWithSidebar from "../../components/page-with-sidebar"; 5 | import VideoCard from "../../components/video-card"; 6 | import playOnScroll from "../../components/play-on-scroll"; 7 | import InfiniteScroll from "react-infinite-scroll-component"; 8 | import { VideoData } from "../../../common/types"; 9 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 10 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 11 | import { getFeed } from "../../../common/api/feed"; 12 | import LoadingSpinner from "../../../common/components/loading-spinner"; 13 | 14 | export function EndMessage() { 15 | return ( 16 |

17 |
18 | Wow, can you believe it? You scrolled through all the videos in our 19 | database! 20 |
21 | window.scrollTo(0, 0)}> 22 | Go to top 23 | 24 |

25 | ); 26 | } 27 | 28 | export default function Home() { 29 | const [feed, setFeed] = useState(null); 30 | const [hasMoreVids, setHasMoreVids] = useState(true); 31 | const dispatch = useAppDispatch(); 32 | const username = useAppSelector(state => state.auth.username); 33 | 34 | const fetchFeed = useCallback( 35 | async (skip?: number) => { 36 | try { 37 | const res = await getFeed(username, skip); 38 | return res.data; 39 | } catch (err: any) { 40 | dispatch( 41 | notificationActions.showNotification({ 42 | type: "error", 43 | message: err.message 44 | }) 45 | ); 46 | return { videos: [] }; 47 | } 48 | }, 49 | [username, dispatch] 50 | ); 51 | 52 | useEffect(() => { 53 | async function feedFunc() { 54 | setFeed((await fetchFeed()).videos); 55 | } 56 | feedFunc(); 57 | }, [dispatch, username, fetchFeed]); 58 | 59 | useEffect(() => { 60 | if (!feed) return; 61 | return playOnScroll("app-video-card"); 62 | }, [feed]); 63 | 64 | const fetchNext = useCallback(async () => { 65 | const res = await fetchFeed(feed!.length); 66 | if (res.videos.length > 0) setFeed(prev => [...prev!, ...res.videos]); 67 | else setHasMoreVids(false); 68 | }, [feed, fetchFeed]); 69 | 70 | return ( 71 | 72 |
73 | {!feed ? ( 74 | 75 | ) : ( 76 | } 81 | endMessage={} 82 | className="infinite-scroll-div" 83 | > 84 | {feed.map(video => ( 85 | 86 | ))} 87 | 88 | )} 89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/common/components/auth-modal/LogIn.tsx: -------------------------------------------------------------------------------- 1 | import { useFormik } from "formik"; 2 | import * as yup from "yup"; 3 | 4 | import { FormProps } from "."; 5 | import Input from "../input-field"; 6 | import { useAppDispatch, useAppSelector } from "../../store"; 7 | import LoadingSpinner from "../loading-spinner"; 8 | import { loginThunk } from "../../store/slices/auth-slice"; 9 | import { notificationActions } from "../../store/slices/notification-slice"; 10 | import constants from "../../constants"; 11 | 12 | const validationSchema = yup.object().shape({ 13 | username: yup 14 | .string() 15 | .trim() 16 | .required("Required") 17 | .min( 18 | constants.usernameMinLen, 19 | `At least ${constants.usernameMinLen} characters` 20 | ) 21 | .max( 22 | constants.usernameMaxLen, 23 | `At most ${constants.usernameMaxLen} characters` 24 | ), 25 | password: yup 26 | .string() 27 | .trim() 28 | .required("Required") 29 | .min( 30 | constants.passwordMinLen, 31 | `At least ${constants.passwordMinLen} characters` 32 | ) 33 | }); 34 | 35 | export default function LogIn({ setAuthType, handleModalClose }: FormProps) { 36 | const dispatch = useAppDispatch(); 37 | const authStatus = useAppSelector(state => state.auth.status); 38 | 39 | const formik = useFormik({ 40 | initialValues: { 41 | username: "", 42 | password: "" 43 | }, 44 | validationSchema, 45 | onSubmit: async values => { 46 | try { 47 | await dispatch(loginThunk(values)).unwrap(); 48 | handleModalClose(); 49 | } catch (err: any) { 50 | dispatch( 51 | notificationActions.showNotification({ 52 | type: "error", 53 | message: err.message 54 | }) 55 | ); 56 | } 57 | } 58 | }); 59 | 60 | return ( 61 | <> 62 |

Log into TikTok

63 |
64 |

Log in via username

65 | 72 | 80 | 93 |
94 |
95 | Don't have an account? 96 | setAuthType("signup")}> Sign up 97 |
98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/mobile/pages/profile/profile.scss: -------------------------------------------------------------------------------- 1 | $header-height: 44px; 2 | $pfp-size: 96px; 3 | 4 | .profile-page { 5 | overflow: auto; 6 | color: var(--clr-text); 7 | background: var(--clr-background); 8 | 9 | .profile-header { 10 | text-align: center; 11 | border-bottom: 2px solid var(--clr-border); 12 | 13 | header { 14 | display: flex; 15 | height: $header-height; 16 | line-height: $header-height; 17 | font-size: 17px; 18 | font-weight: 600; 19 | padding: 0 18px; 20 | border-bottom: 2px solid var(--clr-border); 21 | 22 | > div { 23 | width: 24px; 24 | } 25 | 26 | h4 { 27 | flex: 1 1 0%; 28 | padding: 0 12px; 29 | } 30 | } 31 | 32 | .user-info { 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | gap: 12px; 37 | padding: 12px 0; 38 | 39 | .rounded-photo { 40 | width: $pfp-size; 41 | height: $pfp-size; 42 | } 43 | 44 | h4 { 45 | font-size: 0.9rem; 46 | font-weight: 600; 47 | } 48 | 49 | ul { 50 | display: flex; 51 | width: 100%; 52 | padding: 0 46px; 53 | 54 | li { 55 | display: flex; 56 | flex-direction: column; 57 | width: 33.33%; 58 | line-height: 1.1rem; 59 | } 60 | 61 | .show-divider { 62 | position: relative; 63 | 64 | &::after { 65 | content: ""; 66 | position: absolute; 67 | top: 50%; 68 | right: 0; 69 | background-color: rgba(var(--clr-secondary-values), 0.25); 70 | width: 2px; 71 | height: 16px; 72 | transform: translateY(-50%) scaleX(0.5); 73 | } 74 | } 75 | 76 | span { 77 | color: rgba(var(--clr-secondary-values), 0.6); 78 | font-size: 0.8rem; 79 | } 80 | } 81 | 82 | button { 83 | width: 164px; 84 | height: 44px; 85 | font-size: 0.9rem; 86 | margin: 6px 0; 87 | } 88 | 89 | .description { 90 | font-size: 0.85rem; 91 | padding: 0 40px; 92 | } 93 | } 94 | } 95 | 96 | .video-buttons { 97 | display: flex; 98 | justify-content: space-around; 99 | align-items: center; 100 | position: sticky; 101 | top: 0; 102 | z-index: 5; 103 | background-color: var(--clr-background); 104 | height: 40px; 105 | width: 100%; 106 | border-bottom: 2px solid var(--clr-border); 107 | margin-bottom: 1px; 108 | 109 | button { 110 | font-size: 1.2rem; 111 | color: rgba(var(--clr-secondary-values), 0.3); 112 | height: 95%; 113 | padding: 0 12px; 114 | 115 | &.active { 116 | color: rgba(var(--clr-secondary-values), 0.8); 117 | box-shadow: 0 2px 0 0 rgba(var(--clr-secondary-values), 0.8); 118 | } 119 | } 120 | } 121 | 122 | .videos-container { 123 | display: grid; 124 | grid-template-columns: repeat(3, 1fr); 125 | gap: 1px; 126 | 127 | &.ungrid { 128 | display: block; 129 | } 130 | } 131 | 132 | .no-videos { 133 | text-align: center; 134 | margin-top: 20px; 135 | color: rgba(var(--clr-secondary-values), 0.7); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/mobile/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, lazy, useEffect, useState } from "react"; 2 | import { Route, Routes } from "react-router-dom"; 3 | 4 | import "./index.scss"; 5 | import Notification from "../common/components/notification"; 6 | import FullscreenSpinner from "../common/components/fullscreen-spinner"; 7 | import { useAppSelector } from "../common/store"; 8 | import AuthModal from "../common/components/auth-modal"; 9 | import PrivateRoute from "../common/components/private-route"; 10 | import LegalNotice from "../common/components/legal-notice"; 11 | const Home = lazy(() => import("./pages/home")); 12 | const Following = lazy(() => import("./pages/following")); 13 | const Profile = lazy(() => import("./pages/profile")); 14 | const OwnProfile = lazy(() => import("./pages/profile/OwnProfile")); 15 | const Video = lazy(() => import("./pages/video")); 16 | const EditProfile = lazy(() => import("./pages/edit-profile")); 17 | const Upload = lazy(() => import("./pages/upload")); 18 | const Notifications = lazy(() => import("./pages/notifications")); 19 | const Search = lazy(() => import("./pages/search")); 20 | 21 | export default function MobileLayout() { 22 | const { notification, authModal } = useAppSelector(state => state); 23 | const [showNotice, setShowNotice] = useState(false); 24 | 25 | useEffect(() => { 26 | let usesDarkTheme: any = localStorage.getItem("usesDarkTheme"); 27 | if (usesDarkTheme) { 28 | usesDarkTheme = JSON.parse(usesDarkTheme); 29 | } else { 30 | usesDarkTheme = window.matchMedia("(prefers-color-scheme: dark)").matches; 31 | localStorage.setItem("usesDarkTheme", JSON.stringify(usesDarkTheme)); 32 | } 33 | 34 | document.documentElement.className = usesDarkTheme ? "dark" : "light"; 35 | }, []); 36 | 37 | useEffect(() => { 38 | let hasSeenNotice = localStorage.getItem("hasSeenNotice"); 39 | if (hasSeenNotice) return; 40 | setShowNotice(true); 41 | localStorage.setItem("hasSeenNotice", "true"); 42 | }, []); 43 | 44 | return ( 45 |
46 | {notification.show && ( 47 | 52 | )} 53 | {authModal.show && } 54 | {showNotice && } 55 | }> 56 | 57 | } /> 58 | } /> 59 | } /> 60 | } /> 61 | } /> 62 | } /> 63 | }> 64 | } /> 65 | } /> 66 | } /> 67 | 68 | 69 | 70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/pc/components/sidebar/sidebar.scss: -------------------------------------------------------------------------------- 1 | @import "../../index.scss"; 2 | 3 | $sidebar-width: 356px; 4 | $box-padding: 16px 6px; 5 | $header-color: rgba(var(--clr-secondary-values), 0.75); 6 | $account-box-height: 80px; 7 | $account-pfp-size: 38px; 8 | $bg-transition: background-color 0.25s ease-out; 9 | 10 | .sidebar-wrapper { 11 | width: $sidebar-width; 12 | } 13 | 14 | .app-sidebar { 15 | position: fixed; 16 | top: $header-height; 17 | bottom: 0; 18 | overscroll-behavior-y: contain; 19 | overflow-x: hidden; 20 | overflow-y: auto; 21 | z-index: 10; 22 | display: flex; 23 | flex-direction: column; 24 | background: var(--clr-background); 25 | width: $sidebar-width; 26 | padding-right: 8px; 27 | padding-bottom: 24px; 28 | margin: 4px 0; 29 | 30 | nav { 31 | display: flex; 32 | padding: $box-padding; 33 | padding-bottom: 12px; 34 | flex-direction: column; 35 | font-size: 1.12rem; 36 | font-weight: 600; 37 | border-bottom: 2px solid var(--clr-border); 38 | 39 | .nav-link { 40 | display: flex; 41 | align-items: center; 42 | gap: 12px; 43 | padding: 12px 8px; 44 | border-radius: $btn-border-radius; 45 | 46 | &.active { 47 | color: var(--clr-primary); 48 | } 49 | 50 | i { 51 | font-size: 1.3rem; 52 | } 53 | } 54 | } 55 | 56 | .log-in { 57 | display: flex; 58 | padding: $box-padding; 59 | padding-bottom: 20px; 60 | flex-direction: column; 61 | gap: 22px; 62 | color: rgba(var(--clr-secondary-values), 0.5); 63 | border-bottom: 2px solid var(--clr-border); 64 | font-size: 0.9rem; 65 | } 66 | 67 | .suggested, 68 | .following { 69 | padding: $box-padding; 70 | display: flex; 71 | flex-direction: column; 72 | 73 | header { 74 | display: flex; 75 | justify-content: space-between; 76 | font-size: 0.98rem; 77 | font-weight: 500; 78 | color: $header-color; 79 | margin-bottom: 12px; 80 | } 81 | 82 | .see-all { 83 | color: var(--clr-primary); 84 | font-weight: 500; 85 | margin-top: 8px; 86 | font-size: 0.78rem; 87 | } 88 | 89 | .accounts { 90 | display: flex; 91 | flex-direction: column; 92 | height: $account-box-height; 93 | height: 100%; 94 | 95 | .account-details { 96 | padding: 8px 0; 97 | display: flex; 98 | align-items: center; 99 | gap: 12px; 100 | height: 100%; 101 | border-radius: $btn-border-radius; 102 | } 103 | 104 | .rounded-photo { 105 | height: $account-pfp-size; 106 | width: $account-pfp-size; 107 | } 108 | 109 | .name-container { 110 | h5 { 111 | font-weight: 700; 112 | } 113 | } 114 | } 115 | } 116 | 117 | .following { 118 | border-top: 2px solid var(--clr-border); 119 | border-bottom: 2px solid var(--clr-border); 120 | } 121 | 122 | .spinner { 123 | --circle-size: 12px; 124 | } 125 | 126 | .no-following { 127 | font-size: 0.8rem; 128 | color: rgba(var(--clr-secondary-values), 0.75); 129 | font-weight: 300; 130 | } 131 | 132 | .made-with { 133 | margin-top: 20px; 134 | font-size: 0.75rem; 135 | text-align: center; 136 | color: rgba(var(--clr-secondary-values), 0.7); 137 | 138 | a { 139 | text-decoration: underline; 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/mobile/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef, useCallback } from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | 4 | import PageWithNavbar from "../../components/page-with-navbar"; 5 | import "./home.scss"; 6 | import Swiper from "../../components/swiper"; 7 | import Video from "../../components/video"; 8 | import Drawer from "../../components/drawer"; 9 | import { joinClasses } from "../../../common/utils"; 10 | import { VideoData } from "../../../common/types"; 11 | import LoadingSpinner from "../../../common/components/loading-spinner"; 12 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 13 | import { errorNotification } from "../../helpers/error-notification"; 14 | import { getFeed, getFollowingVids } from "../../../common/api/feed"; 15 | 16 | interface Props { 17 | showFollowing?: boolean; 18 | } 19 | 20 | export default function HomePage({ showFollowing }: Props) { 21 | const dispatch = useAppDispatch(); 22 | const { username } = useAppSelector(state => state.auth); 23 | const [feed, setFeed] = useState(null); 24 | const [showDrawer, setShowDrawer] = useState(false); 25 | const hasNext = useRef(true); 26 | 27 | useEffect(() => { 28 | errorNotification( 29 | async () => { 30 | let res: any; 31 | if (showFollowing) { 32 | res = await getFollowingVids(username!); 33 | } else { 34 | res = await getFeed(username); 35 | } 36 | setFeed(res.data.videos); 37 | }, 38 | dispatch, 39 | () => setFeed([]), 40 | "Couldn't load videos:" 41 | ); 42 | }, [dispatch, username, showFollowing]); 43 | 44 | const fetchNext = useCallback(async () => { 45 | if (!hasNext.current) return; 46 | let res: any; 47 | try { 48 | if (showFollowing) res = await getFollowingVids(username!, feed!.length); 49 | else res = await getFeed(username, feed!.length); 50 | 51 | if (res.data.videos.length < 1) { 52 | hasNext.current = false; 53 | return; 54 | } 55 | setFeed(prev => [...prev!, ...res.data.videos]); 56 | } catch (err: any) { 57 | console.error("Couldn't fetch more videos.", err); 58 | } 59 | }, [feed, username, showFollowing]); 60 | 61 | return ( 62 | 63 |
64 | 68 | 86 |
87 | {!feed ? ( 88 |
89 | 90 |
91 | ) : ( 92 | ( 94 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/mobile/components/notification-box/index.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from "react"; 2 | import { Link, useNavigate } from "react-router-dom"; 3 | 4 | import classes from "./notification-box.module.scss"; 5 | import { UserNotification } from "../../../common/types"; 6 | import constants from "../../../common/constants"; 7 | import { convertToDate, joinClasses } from "../../../common/utils"; 8 | import { errorNotification } from "../../helpers/error-notification"; 9 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 10 | import { deleteNotif } from "../../../common/api/user"; 11 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 12 | 13 | interface Props extends UserNotification { 14 | setNotifs: React.Dispatch>; 15 | fetchNotifs: () => void; 16 | } 17 | 18 | export default function NotificationBox(props: Props) { 19 | const navigate = useNavigate(); 20 | const dispatch = useAppDispatch(); 21 | const { username, token } = useAppSelector(state => state.auth); 22 | 23 | function handleRedirect() { 24 | navigate( 25 | props.meta 26 | ? "/video/" + props.meta.videoId 27 | : props.type === "likedVideo" 28 | ? "/video/" + props.refId 29 | : "/user/" + props.by.username 30 | ); 31 | } 32 | 33 | function handleDelete(e: MouseEvent) { 34 | e.stopPropagation(); 35 | errorNotification( 36 | async () => { 37 | await deleteNotif(username!, token!, props._id!); 38 | dispatch( 39 | notificationActions.showNotification({ 40 | type: "success", 41 | message: "Notification deleted" 42 | }) 43 | ); 44 | props.setNotifs(null); 45 | props.fetchNotifs(); 46 | }, 47 | dispatch, 48 | null, 49 | "Couldn't delete notification:" 50 | ); 51 | } 52 | 53 | return ( 54 |
61 | e.stopPropagation()} 65 | > 66 | {props.by.username} 70 | 71 |
72 | e.stopPropagation()} 76 | > 77 | {props.by.username} 78 | 79 |

{props.message}

80 | {convertToDate(props.createdAt)} 81 |
82 |
83 | {(props.meta || props.type === "likedVideo") && ( 84 |
85 |
97 | )} 98 | 99 |
100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/common/styles.scss: -------------------------------------------------------------------------------- 1 | $default-fonts: "Poppins", Arial, Tahoma, sans-serif, "Segoe UI Emoji", 2 | "Noto Emoji"; 3 | 4 | *, 5 | *::before, 6 | *::after { 7 | margin: 0; 8 | padding: 0; 9 | box-sizing: border-box; 10 | } 11 | 12 | /* Base light theme class for the website. Define everything light-theme related in this class and *nowhere* else. The class is added to the root (html) tag. */ 13 | 14 | :root.light { 15 | --clr-background: #ffffff; 16 | --clr-shadow-values: 0, 0, 0; 17 | --clr-shadow: rgba(var(--clr-shadow-values), 0.12); 18 | --clr-border: #e5e6e7; 19 | 20 | --clr-primary-values: 254, 44, 85; 21 | --clr-primary: rgb(var(--clr-primary-values)); 22 | 23 | --clr-secondary-values: 22, 24, 35; 24 | --clr-secondary: rgb(var(--clr-secondary-values)); 25 | 26 | --clr-text: rgb(var(--clr-secondary-values)); 27 | 28 | --clr-scrollbar-thumb: rgba(var(--clr-primary-values), 0.85); 29 | --clr-scrollbar-track: rgba(var(--clr-secondary-values), 0.25); 30 | } 31 | 32 | /* light theme end */ 33 | 34 | /* Base dark theme class for the website. Define everything dark-theme related in this class and *nowhere* else. The class is added to the root (html) tag. */ 35 | 36 | :root.dark { 37 | --clr-background: #181a1b; 38 | --clr-shadow-values: 255, 255, 255; 39 | --clr-shadow: rgba(var(--clr-shadow-values), 0.2); 40 | --clr-border: #494949; 41 | 42 | --clr-primary-values: 254, 44, 85; 43 | --clr-primary: rgb(var(--clr-primary-values)); 44 | 45 | --clr-secondary-values: 214, 210, 205; 46 | --clr-secondary: rgb(var(--clr-secondary-values)); 47 | 48 | --clr-text: rgb(var(--clr-secondary-values)); 49 | 50 | --clr-scrollbar-thumb: rgba(var(--clr-primary-values), 0.85); 51 | --clr-scrollbar-track: rgb(65, 65, 65); 52 | 53 | --clr-bg-elevation-1: #232425; 54 | --clr-bg-elevation-2: #373838; 55 | 56 | --logo-filter: invert(1); 57 | --shadows: none; 58 | } 59 | 60 | html, 61 | body { 62 | overflow-x: hidden; 63 | background-color: var(--clr-background); 64 | } 65 | 66 | body { 67 | font-family: $default-fonts; 68 | } 69 | 70 | button { 71 | background: none; 72 | font-family: inherit; 73 | font-size: inherit; 74 | font-weight: inherit; 75 | border: none; 76 | color: inherit; 77 | cursor: pointer; 78 | user-select: none; 79 | } 80 | 81 | a { 82 | text-decoration: none; 83 | color: inherit; 84 | cursor: pointer; 85 | font-size: inherit; 86 | } 87 | 88 | input { 89 | font-family: inherit; 90 | border: none; 91 | background: none; 92 | color: inherit; 93 | outline: none; 94 | } 95 | 96 | ul, 97 | ol { 98 | list-style: none; 99 | } 100 | 101 | img, 102 | video { 103 | object-fit: cover; 104 | object-position: center; 105 | user-select: none; 106 | } 107 | 108 | h1, 109 | h2, 110 | h3, 111 | h4, 112 | h5, 113 | h6 { 114 | font-weight: inherit; 115 | } 116 | 117 | .image-container { 118 | height: 100%; 119 | display: flex; 120 | align-items: center; 121 | 122 | img { 123 | height: 100%; 124 | width: 100%; 125 | } 126 | } 127 | 128 | .rounded-photo { 129 | border-radius: 50%; 130 | overflow: hidden; 131 | 132 | img { 133 | height: 100%; 134 | width: 100%; 135 | } 136 | } 137 | 138 | .break-word { 139 | overflow-wrap: break-word; 140 | word-wrap: break-word; 141 | word-break: break-all; 142 | word-break: break-word; 143 | } 144 | 145 | .backdrop { 146 | position: fixed; 147 | inset: 0; 148 | background: rgba(0, 0, 0, 0.6); 149 | user-select: none; 150 | } 151 | -------------------------------------------------------------------------------- /src/pc/components/video-modal/LoadVideoModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import copy from "copy-to-clipboard"; 3 | 4 | import "./video-modal.scss"; 5 | import Modal from "."; 6 | import FullscreenSpinner from "../../../common/components/fullscreen-spinner"; 7 | import useVideoDynamics, { 8 | videoDynamicsActions 9 | } from "../video-card/useVideoDynamics"; 10 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 11 | import { VideoData } from "../../../common/types"; 12 | import { getVideo, share } from "../../../common/api/video"; 13 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 14 | 15 | export interface ModalProps { 16 | videoId: string; 17 | setShowModal: React.Dispatch>; 18 | } 19 | 20 | export default function LoadVideoModal({ videoId, setShowModal }: ModalProps) { 21 | const dispatch = useAppDispatch(); 22 | const username = useAppSelector(state => state.auth.username); 23 | const [videoData, setVideoData] = useState(null); 24 | const [vidDynamics, vidDispatch] = useVideoDynamics({ 25 | hasLiked: false, 26 | likesNum: 0, 27 | isFollowing: false, 28 | commentsNum: 0 29 | }); 30 | 31 | useEffect(() => { 32 | async function fetchVid() { 33 | try { 34 | const res = await getVideo(videoId, username); 35 | setVideoData(res.data); 36 | } catch (err: any) { 37 | dispatch( 38 | notificationActions.showNotification({ 39 | type: "error", 40 | message: err.message 41 | }) 42 | ); 43 | } 44 | } 45 | fetchVid(); 46 | }, [dispatch, videoId, username]); 47 | 48 | useEffect(() => { 49 | if (!videoData) return; 50 | vidDispatch({ 51 | type: videoDynamicsActions.ALL, 52 | payload: { 53 | hasLiked: videoData.hasLiked!, 54 | likesNum: videoData.likes!, 55 | isFollowing: videoData.isFollowing, 56 | commentsNum: videoData.comments as number 57 | } 58 | }); 59 | }, [videoData, vidDispatch]); 60 | 61 | const handleLike = useCallback( 62 | (hasLiked: boolean) => { 63 | vidDispatch({ 64 | type: videoDynamicsActions.LIKED, 65 | hasLiked 66 | }); 67 | }, 68 | [vidDispatch] 69 | ); 70 | 71 | const handleFollow = useCallback( 72 | (isFollowing: boolean) => { 73 | vidDispatch({ type: videoDynamicsActions.FOLLOWED, isFollowing }); 74 | }, 75 | [vidDispatch] 76 | ); 77 | 78 | const handleCommentsChange = useCallback( 79 | (commentsNum: number) => { 80 | vidDispatch({ type: videoDynamicsActions.COMMENTED, commentsNum }); 81 | }, 82 | [vidDispatch] 83 | ); 84 | 85 | const handleShare = useCallback(async () => { 86 | try { 87 | copy(window.location.origin + "/video/" + videoId); 88 | dispatch( 89 | notificationActions.showNotification({ 90 | type: "success", 91 | message: "Video link copied to clipboard" 92 | }) 93 | ); 94 | await share(videoId); 95 | } catch (err: any) { 96 | dispatch( 97 | notificationActions.showNotification({ 98 | type: "error", 99 | message: err.message 100 | }) 101 | ); 102 | } 103 | }, [dispatch, videoId]); 104 | 105 | return videoData ? ( 106 | 115 | ) : ( 116 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /src/mobile/pages/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useLocation } from "react-router-dom"; 3 | 4 | import PageWithNavbar from "../../components/page-with-navbar"; 5 | import "./search.scss"; 6 | import SearchBar from "../../components/search-bar"; 7 | import SearchedVideo from "../../components/searched-video"; 8 | import SearchedAccount from "../../components/searched-account"; 9 | import { errorNotification } from "../../helpers/error-notification"; 10 | import { useAppDispatch } from "../../../common/store"; 11 | import { UserData, VideoData } from "../../../common/types"; 12 | import { search } from "../../../common/api/feed"; 13 | import LoadingSpinner from "../../../common/components/loading-spinner"; 14 | 15 | type sendType = "videos" | "accounts"; 16 | 17 | export default function Search() { 18 | const dispatch = useAppDispatch(); 19 | const params = new URLSearchParams(useLocation().search); 20 | const [query, setQuery] = useState(params.get("query") || ""); 21 | const [send, setSend] = useState( 22 | (params.get("send") as sendType) || "videos" 23 | ); 24 | const [videos, setVideos] = useState(null); 25 | const [accounts, setAccounts] = useState(null); 26 | 27 | useEffect(() => { 28 | if (!query) return; 29 | errorNotification( 30 | async () => { 31 | if (send === "videos") setVideos(null); 32 | else setAccounts(null); 33 | const res = await search(query, send); 34 | if (send === "videos") setVideos(res.data.videos); 35 | else setAccounts(res.data.accounts); 36 | }, 37 | dispatch, 38 | () => { 39 | if (send === "videos") setVideos([]); 40 | else setAccounts([]); 41 | }, 42 | "Couldn't load results:" 43 | ); 44 | }, [dispatch, query, send]); 45 | 46 | return ( 47 | 48 |
49 | 50 |
51 | {query && ( 52 |
53 |
54 | 60 | 66 |
67 | {send === "videos" ? ( 68 | !videos ? ( 69 | 70 | ) : ( 71 | <> 72 | {videos.length === 0 ? ( 73 |
74 | No videos match your query "{query}". 75 |
76 | ) : ( 77 |
78 | {videos.map((vid, i) => ( 79 | 80 | ))} 81 |
82 | )} 83 | 84 | ) 85 | ) : !accounts ? ( 86 | 87 | ) : ( 88 | <> 89 | {accounts.length === 0 ? ( 90 |
91 | No accounts match your query "{query}". 92 |
93 | ) : ( 94 |
95 | {accounts.map((acc, i) => ( 96 | 97 | ))} 98 |
99 | )} 100 | 101 | )} 102 |
103 | )} 104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /src/mobile/components/drawer/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from "react"; 2 | 3 | import "./drawer.scss"; 4 | import AccountBox from "./AccountBox"; 5 | import LoadingSpinner from "../../../common/components/loading-spinner"; 6 | import { UserData } from "../../../common/types"; 7 | import { errorNotification } from "../../helpers/error-notification"; 8 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 9 | import { getSuggested } from "../../../common/api/feed"; 10 | import { getCustom } from "../../../common/api/user"; 11 | 12 | interface Props { 13 | setShowDrawer: React.Dispatch>; 14 | } 15 | 16 | export default function Drawer({ setShowDrawer }: Props) { 17 | const dispatch = useAppDispatch(); 18 | const username = useAppSelector(state => state.auth.username); 19 | const drawerRef = useRef(null); 20 | const backdropRef = useRef(null); 21 | const [suggested, setSuggested] = useState(null); 22 | const [following, setFollowing] = useState(null); 23 | 24 | useEffect(() => { 25 | if (!drawerRef.current || !backdropRef.current) return; 26 | drawerRef.current.classList.add("reveal"); 27 | backdropRef.current.classList.add("show"); 28 | }, []); 29 | 30 | useEffect(() => { 31 | errorNotification( 32 | async () => { 33 | const res = await getSuggested(5); 34 | setSuggested(res.data.users); 35 | }, 36 | dispatch, 37 | () => setSuggested([]), 38 | "Couldn't load suggested accounts:" 39 | ); 40 | 41 | if (!username) { 42 | setFollowing([]); 43 | return; 44 | } 45 | errorNotification( 46 | async () => { 47 | const res = await getCustom({ following: "list" }, username); 48 | setFollowing(res.data.following); 49 | }, 50 | dispatch, 51 | () => setFollowing([]), 52 | "Couldn't load accounts you follow:" 53 | ); 54 | }, [dispatch, username]); 55 | 56 | function handleClose() { 57 | if (!drawerRef.current || !backdropRef.current) return; 58 | drawerRef.current.classList.remove("reveal"); 59 | drawerRef.current.classList.add("hide"); 60 | 61 | backdropRef.current.classList.remove("show"); 62 | backdropRef.current.classList.add("hide"); 63 | 64 | setTimeout(() => { 65 | setShowDrawer(false); 66 | }, 300); 67 | } 68 | 69 | return ( 70 | <> 71 |
76 | 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /src/mobile/pages/upload/upload.scss: -------------------------------------------------------------------------------- 1 | $header-height: 44px; 2 | 3 | .upload-page { 4 | background: var(--clr-background); 5 | color: var(--clr-text); 6 | overflow: auto; 7 | padding-bottom: 20px; 8 | 9 | .box-backdrop { 10 | z-index: 11; 11 | } 12 | 13 | .progress-box { 14 | display: flex; 15 | flex-direction: column; 16 | gap: 8px; 17 | position: fixed; 18 | top: 50%; 19 | left: 50%; 20 | width: 90%; 21 | transform: translate(-50%, -50%); 22 | z-index: 12; 23 | background: var(--clr-bg-elevation-2, var(--clr-background)); 24 | box-shadow: var(--shadows, 0 0 5px 5px var(--clr-shadow)); 25 | border-radius: 8px; 26 | padding: 16px 20px; 27 | text-align: center; 28 | 29 | h3 { 30 | font-weight: 500; 31 | font-size: 1.3rem; 32 | margin-bottom: 8px; 33 | } 34 | 35 | h5 { 36 | font-size: inherit; 37 | display: flex; 38 | align-items: baseline; 39 | gap: 5px; 40 | } 41 | 42 | p { 43 | font-weight: 600; 44 | font-size: 1.2rem; 45 | } 46 | 47 | span { 48 | font-size: 0.7rem; 49 | } 50 | 51 | button { 52 | margin-top: 4px; 53 | border: none; 54 | align-self: center; 55 | background: none; 56 | font-weight: 400; 57 | color: var(--clr-text); 58 | 59 | &:disabled { 60 | color: rgba(var(--clr-secondary-values), 0.6); 61 | } 62 | } 63 | } 64 | 65 | header { 66 | height: $header-height; 67 | line-height: $header-height; 68 | font-size: 17px; 69 | font-weight: 600; 70 | padding: 0 18px; 71 | text-align: center; 72 | border-bottom: 2px solid var(--clr-border); 73 | margin-bottom: 20px; 74 | } 75 | 76 | .content { 77 | padding: 0 20px; 78 | } 79 | 80 | .video-container { 81 | display: flex; 82 | flex-direction: column; 83 | justify-content: center; 84 | text-align: center; 85 | border: 2px dashed var(--clr-primary); 86 | border-radius: 8px; 87 | width: 80%; 88 | aspect-ratio: 9 / 16; 89 | margin: 0 auto 36px; 90 | padding: 12px; 91 | font-size: 0.9rem; 92 | font-weight: 500; 93 | 94 | &.playing { 95 | padding: 0; 96 | overflow: hidden; 97 | } 98 | 99 | i { 100 | color: var(--clr-primary); 101 | font-size: 1.5rem; 102 | } 103 | 104 | h4 { 105 | margin: 8px 0 12px; 106 | } 107 | 108 | p { 109 | display: flex; 110 | flex-direction: column; 111 | font-size: 0.77rem; 112 | color: rgba(var(--clr-secondary-values), 0.7); 113 | gap: 6px; 114 | } 115 | } 116 | 117 | .info-container { 118 | display: flex; 119 | flex-direction: column; 120 | gap: 16px; 121 | 122 | .buttons { 123 | display: flex; 124 | justify-content: center; 125 | gap: 2%; 126 | flex: 1 1 0%; 127 | margin-top: 8px; 128 | 129 | button { 130 | width: 48%; 131 | font-size: 0.8rem; 132 | } 133 | 134 | .cancel-button { 135 | border-radius: 4px; 136 | } 137 | } 138 | } 139 | 140 | .form-group { 141 | display: flex; 142 | flex-direction: column; 143 | gap: 8px; 144 | 145 | h5 { 146 | display: flex; 147 | flex-direction: column; 148 | gap: 2px; 149 | 150 | span { 151 | font-size: 0.65rem; 152 | line-height: 0.9rem; 153 | color: rgba(var(--clr-secondary-values), 0.7); 154 | } 155 | } 156 | 157 | label { 158 | font-size: 0.9rem; 159 | font-weight: 500; 160 | } 161 | 162 | .error-container { 163 | font-size: 0.65rem; 164 | } 165 | } 166 | 167 | #video { 168 | display: none; 169 | } 170 | 171 | .player { 172 | width: 100%; 173 | height: 100%; 174 | object-fit: contain; 175 | background-color: #000; 176 | } 177 | 178 | .spinner { 179 | --circle-size: 12px; 180 | --wrapper-padding: 4px; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/mobile/components/comments-modal/AddComment.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useFormik } from "formik"; 3 | import * as yup from "yup"; 4 | 5 | import "./comments-modal.scss"; 6 | import Input from "../../../common/components/input-field"; 7 | import constants from "../../../common/constants"; 8 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 9 | import { errorNotification } from "../../helpers/error-notification"; 10 | import { postComment, reply } from "../../../common/api/video"; 11 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 12 | import { CommentData } from "../../../common/types"; 13 | import { ReplyTo } from "."; 14 | 15 | interface Props { 16 | videoId: string; 17 | fetchComments: () => void; 18 | setComments: React.Dispatch>; 19 | replyTo: ReplyTo | null; 20 | setReplyTo: React.Dispatch>; 21 | } 22 | 23 | const validationSchema = yup.object().shape({ 24 | comment: yup 25 | .string() 26 | .required("") 27 | .max( 28 | constants.commentMaxLen, 29 | `At most ${constants.commentMaxLen} characters` 30 | ) 31 | }); 32 | 33 | export default function AddComment({ 34 | fetchComments, 35 | videoId, 36 | setComments, 37 | replyTo, 38 | setReplyTo 39 | }: Props) { 40 | const dispatch = useAppDispatch(); 41 | const { username, token } = useAppSelector(state => state.auth); 42 | const inputRef = useRef(null); 43 | const formik = useFormik({ 44 | initialValues: { 45 | comment: "" 46 | }, 47 | validationSchema, 48 | onSubmit: ({ comment }) => { 49 | errorNotification( 50 | async () => { 51 | if (replyTo) { 52 | await reply(comment, replyTo.commentId, videoId, username!, token!); 53 | dispatch( 54 | notificationActions.showNotification({ 55 | type: "success", 56 | message: "Reply posted" 57 | }) 58 | ); 59 | formik.setFieldValue("comment", ""); 60 | replyTo.setRepliesNum(prev => prev + 1); 61 | replyTo.setReplies(null); 62 | replyTo.setShowReplies(true); 63 | replyTo.fetchReplies(); 64 | setReplyTo(null); 65 | return; 66 | } 67 | await postComment(username!, comment, videoId, token!); 68 | dispatch( 69 | notificationActions.showNotification({ 70 | type: "success", 71 | message: "Comment posted" 72 | }) 73 | ); 74 | formik.setFieldValue("comment", ""); 75 | setComments(null); 76 | fetchComments(); 77 | }, 78 | dispatch, 79 | null, 80 | "Couldn't post " + (replyTo ? "reply:" : "comment:") 81 | ); 82 | } 83 | }); 84 | 85 | useEffect(() => { 86 | if (!replyTo || !inputRef.current) return; 87 | inputRef.current.focus(); 88 | }, [replyTo]); 89 | 90 | return ( 91 |
92 | 103 | 110 | {replyTo && ( 111 | 119 | )} 120 | 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/pc/pages/following/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | 3 | import "./following.scss"; 4 | import PageWithSidebar from "../../components/page-with-sidebar"; 5 | import LoadingSpinner from "../../../common/components/loading-spinner"; 6 | import VideoCard from "../../components/video-card"; 7 | import SuggestionCard from "../../components/suggestion-card"; 8 | import InfiniteScroll from "react-infinite-scroll-component"; 9 | import { EndMessage } from "../home"; 10 | import playOnScroll from "../../components/play-on-scroll"; 11 | import { useAppSelector, useAppDispatch } from "../../../common/store"; 12 | import { getFollowingVids, getSuggested } from "../../../common/api/feed"; 13 | import { UserData, VideoData } from "../../../common/types"; 14 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 15 | import { joinClasses } from "../../../common/utils"; 16 | 17 | // should always match the "followingLimit" in the backend 18 | const skipValue = 5; 19 | let skip = skipValue * 2; 20 | 21 | export default function Following() { 22 | const dispatch = useAppDispatch(); 23 | const { isAuthenticated: isAuthed, username } = useAppSelector( 24 | state => state.auth 25 | ); 26 | const [hasMoreVids, setHasMoreVids] = useState(true); 27 | const [videos, setVideos] = useState(null); 28 | const [suggestions, setSuggestions] = useState(null); 29 | const [showVideos, setShowVideos] = useState(true); 30 | 31 | const fetchVids = useCallback( 32 | async (skip?: number) => { 33 | try { 34 | const res = await getFollowingVids(username!, skip); 35 | return res.data; 36 | } catch (err: any) { 37 | dispatch( 38 | notificationActions.showNotification({ 39 | type: "error", 40 | message: err.message 41 | }) 42 | ); 43 | } 44 | }, 45 | [username, dispatch] 46 | ); 47 | 48 | useEffect(() => { 49 | if (!isAuthed) { 50 | setShowVideos(false); 51 | return; 52 | } 53 | async function fetchFunc() { 54 | const res = await fetchVids(); 55 | if (res.videos.length === 0) setShowVideos(false); 56 | else setVideos(res.videos); 57 | } 58 | fetchFunc(); 59 | }, [dispatch, isAuthed, fetchVids]); 60 | 61 | useEffect(() => { 62 | if (!videos) return; 63 | return playOnScroll("app-video-card"); 64 | }, [videos]); 65 | 66 | useEffect(() => { 67 | if (showVideos) return; 68 | async function fetchSuggestions() { 69 | try { 70 | const res = await getSuggested(); 71 | setSuggestions(res.data.users); 72 | } catch (err: any) { 73 | dispatch( 74 | notificationActions.showNotification({ 75 | type: "error", 76 | message: err.message 77 | }) 78 | ); 79 | } 80 | } 81 | fetchSuggestions(); 82 | }, [isAuthed, dispatch, showVideos]); 83 | 84 | const fetchNext = useCallback(async () => { 85 | const res = await fetchVids(skip); 86 | skip += skipValue; 87 | if (res.videos.length > 0) setVideos(prev => [...prev!, ...res.videos]); 88 | else setHasMoreVids(false); 89 | }, [fetchVids]); 90 | 91 | return ( 92 | 93 | {showVideos ? ( 94 |
95 | {!videos ? ( 96 | 97 | ) : ( 98 | } 103 | endMessage={} 104 | className="infinite-scroll-div" 105 | > 106 | {videos.map((vid, i) => ( 107 | 108 | ))} 109 | 110 | )} 111 |
112 | ) : ( 113 |
119 | {!suggestions ? ( 120 | 121 | ) : ( 122 | suggestions.map((user, i) => ) 123 | )} 124 |
125 | )} 126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /src/pc/index.scss: -------------------------------------------------------------------------------- 1 | // pc specific variables 2 | $viewport-max-width: 1200px; 3 | $btn-border-radius: 4px; 4 | $header-height: 60px; 5 | $scrollbar-width: 8px; 6 | /* this comment keeps track of the current maximum z-index. Change this comment if a need for an even greater z-index arrives and update the component next to it with the one that will have the higher z-index. ! Do not set it to an absurdly high value that gets difficult to keep track of (like 99999) ! */ 7 | // cur-max-z-index: 25; notification 8 | 9 | * { 10 | scrollbar-width: thin; 11 | scrollbar-color: var(--clr-scrollbar-thumb) var(--clr-scrollbar-track); 12 | } 13 | 14 | *::-webkit-scrollbar { 15 | width: $scrollbar-width; 16 | } 17 | 18 | *::-webkit-scrollbar-thumb { 19 | background-color: var(--clr-scrollbar-thumb); 20 | } 21 | 22 | *::-webkit-scrollbar-track { 23 | background-color: var(--clr-scrollbar-track); 24 | } 25 | 26 | .page-container { 27 | min-height: 100vh; 28 | background: var(--clr-background); 29 | color: var(--clr-text); 30 | position: relative; 31 | } 32 | 33 | .primary-button { 34 | color: #ffffff; 35 | background-color: var(--clr-primary); 36 | border: 1px solid var(--clr-primary); 37 | border-radius: $btn-border-radius; 38 | padding: 8px; 39 | font-weight: 600; 40 | 41 | &:hover { 42 | background: linear-gradient(0deg, rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.06)), 43 | var(--clr-primary); 44 | } 45 | 46 | &:disabled { 47 | background: rgba(var(--clr-secondary-values), 0.06); 48 | color: rgba(var(--clr-secondary-values), 0.34); 49 | border-color: transparent; 50 | cursor: not-allowed; 51 | } 52 | } 53 | 54 | .secondary-button { 55 | color: var(--clr-primary); 56 | font-weight: 600; 57 | padding: 8px; 58 | border-radius: $btn-border-radius; 59 | border: 1px solid var(--clr-primary); 60 | 61 | &:hover { 62 | background-color: rgba(var(--clr-primary-values), 0.06); 63 | } 64 | } 65 | 66 | .info-button { 67 | color: rgba(var(--clr-secondary-values), 0.8); 68 | font-weight: 600; 69 | font-size: 1rem; 70 | padding: 8px; 71 | border-radius: $btn-border-radius; 72 | border: 1px solid var(--clr-text); 73 | 74 | &:hover { 75 | background-color: rgba(var(--clr-secondary-values), 0.06); 76 | } 77 | } 78 | 79 | .hoverable { 80 | cursor: pointer; 81 | transition: background-color 0.25s ease-out; 82 | 83 | &:hover { 84 | background-color: rgba(var(--clr-secondary-values), 0.03); 85 | } 86 | } 87 | 88 | .follow-btn { 89 | button { 90 | border: 1px solid var(--clr-primary); 91 | border-radius: $btn-border-radius; 92 | padding: 2px 16px; 93 | color: var(--clr-primary); 94 | font-size: 0.9rem; 95 | font-weight: 500; 96 | 97 | &:hover { 98 | background-color: rgba(var(--clr-primary-values), 0.06); 99 | } 100 | 101 | &.info-button { 102 | color: rgba(var(--clr-secondary-values), 0.8); 103 | border-color: rgba(var(--clr-secondary-values), 0.8); 104 | 105 | &:hover { 106 | background-color: rgba(var(--clr-secondary-values), 0.06); 107 | } 108 | } 109 | } 110 | } 111 | 112 | .tags { 113 | font-size: 0.85rem; 114 | font-weight: 600; 115 | 116 | span:hover, 117 | a:hover { 118 | cursor: pointer; 119 | text-decoration: underline; 120 | } 121 | } 122 | 123 | .action-btn-container.liked { 124 | .action-btn { 125 | background: var(--clr-primary); 126 | 127 | i { 128 | color: #fff; 129 | } 130 | } 131 | } 132 | 133 | .clickable { 134 | cursor: pointer; 135 | user-select: none; 136 | } 137 | 138 | .scrolled-all { 139 | display: flex; 140 | align-items: center; 141 | justify-content: center; 142 | flex-direction: column; 143 | gap: 4px; 144 | margin: 48px 0; 145 | 146 | span { 147 | font-weight: 600; 148 | font-size: 1.1rem; 149 | 150 | &:hover { 151 | text-decoration: underline; 152 | } 153 | } 154 | } 155 | 156 | .infinite-scroll-div { 157 | overflow: unset !important; 158 | } 159 | 160 | .clamp-text { 161 | display: -webkit-box; 162 | -webkit-line-clamp: 2; 163 | -webkit-box-orient: vertical; 164 | -moz-box-orient: vertical; 165 | overflow: hidden; 166 | text-overflow: ellipsis; 167 | -moz-box-orient: vertical; 168 | word-break: break-word; 169 | } 170 | -------------------------------------------------------------------------------- /src/pc/components/video-tag/video-tag.module.scss: -------------------------------------------------------------------------------- 1 | $slider-width-number: 900; 2 | $slider-width: #{$slider-width-number}px; 3 | $slider-height: 4px; 4 | $background-slider: rgb(129, 129, 129); 5 | $background-filled-slider: #fff; 6 | $thumb-size: 20px; 7 | $thumb-border: none; 8 | $thumb-radius: 50%; 9 | $thumb-background: #fff; 10 | $shadow-size: -8px; 11 | $fit-thumb-in-slider: -8px; 12 | 13 | @function make-long-shadow($color, $size) { 14 | $val: 5px 0 0 $size $color; 15 | 16 | @for $i from 6 through $slider-width-number { 17 | $val: #{$val}, #{$i}px 0 0 $size #{$color}; 18 | } 19 | 20 | @return $val; 21 | } 22 | 23 | .video-tag-container { 24 | position: relative; 25 | 26 | .spinner { 27 | position: absolute; 28 | inset: 0; 29 | display: flex; 30 | align-items: center; 31 | justify-content: center; 32 | background: rgba(var(--clr-secondary-values), 0.2); 33 | z-index: 4; 34 | } 35 | 36 | .controls { 37 | position: absolute; 38 | inset: 0; 39 | z-index: 3; 40 | color: #fff; 41 | font-size: 2.7rem; 42 | pointer-events: none; 43 | transition: opacity 0.2s ease-out; 44 | 45 | .button { 46 | pointer-events: all; 47 | text-shadow: 0 0 5px #000; 48 | } 49 | 50 | .center-btn { 51 | position: absolute; 52 | top: 50%; 53 | left: 50%; 54 | transform: translate(-50%, -50%); 55 | } 56 | 57 | .volume-btn { 58 | position: absolute; 59 | top: 24px; 60 | right: 16px; 61 | font-size: 2rem; 62 | width: 40px; 63 | } 64 | 65 | .seek-bar { 66 | position: absolute; 67 | bottom: 0; 68 | padding: 0 16px 12px; 69 | left: 50%; 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | gap: 4px; 74 | transform: translateX(-50%); 75 | width: 100%; 76 | 77 | input { 78 | appearance: none; 79 | height: 100%; 80 | min-height: 50px; 81 | width: 100%; 82 | overflow: hidden; 83 | 84 | &::-webkit-slider-runnable-track { 85 | background: $background-filled-slider; 86 | height: $slider-height; 87 | pointer-events: none; 88 | box-shadow: 0 0 1px 1px #000; 89 | } 90 | 91 | &::-webkit-slider-thumb { 92 | width: $thumb-size; 93 | height: $thumb-size; 94 | appearance: none; 95 | background: $thumb-background; 96 | border-radius: $thumb-radius; 97 | box-shadow: make-long-shadow($background-slider, $shadow-size); 98 | margin-top: $fit-thumb-in-slider; 99 | border: $thumb-border; 100 | } 101 | 102 | &::-moz-range-track { 103 | width: $slider-width; 104 | height: $slider-height; 105 | background: $background-slider; 106 | box-shadow: 0 0 1px 1px #000; 107 | } 108 | 109 | &::-moz-range-thumb { 110 | width: $thumb-size; 111 | height: $thumb-size; 112 | background: $thumb-background; 113 | border-radius: $thumb-radius; 114 | border: $thumb-border; 115 | position: relative; 116 | box-shadow: make-long-shadow($background-slider, $shadow-size); 117 | } 118 | 119 | &::-moz-range-progress { 120 | height: $slider-height; 121 | background: $background-filled-slider; 122 | } 123 | 124 | &::-ms-track { 125 | background: transparent; 126 | border: 0; 127 | border-color: transparent; 128 | border-radius: 0; 129 | border-width: 0; 130 | color: transparent; 131 | height: $slider-height; 132 | margin-top: 10px; 133 | width: $slider-width; 134 | box-shadow: 0 0 1px 1px #000; 135 | } 136 | 137 | &::-ms-thumb { 138 | width: $thumb-size; 139 | height: $thumb-size; 140 | 141 | background: $thumb-background; 142 | border-radius: $thumb-radius; 143 | border: $thumb-border; 144 | } 145 | 146 | &::-ms-fill-lower { 147 | background: $background-filled-slider; 148 | border-radius: 0; 149 | } 150 | 151 | &::-ms-fill-upper { 152 | background: $background-slider; 153 | border-radius: 0; 154 | } 155 | 156 | &::-ms-tooltip { 157 | display: none; 158 | } 159 | } 160 | 161 | span { 162 | text-align: right; 163 | font-size: 0.8rem; 164 | min-width: 88px; 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/common/components/auth-modal/SignUp.tsx: -------------------------------------------------------------------------------- 1 | import { useFormik } from "formik"; 2 | import * as yup from "yup"; 3 | 4 | import { FormProps } from "."; 5 | import Input from "../input-field"; 6 | import LoadingSpinner from "../loading-spinner"; 7 | import { useAppDispatch, useAppSelector } from "../../store"; 8 | import { signupThunk } from "../../store/slices/auth-slice"; 9 | import { notificationActions } from "../../store/slices/notification-slice"; 10 | import constants from "../../constants"; 11 | 12 | const validationSchema = yup.object().shape({ 13 | email: yup.string().trim().required("Required").email("Invalid email"), 14 | username: yup 15 | .string() 16 | .trim() 17 | .required("Required") 18 | .min( 19 | constants.usernameMinLen, 20 | `At least ${constants.usernameMinLen} characters` 21 | ) 22 | .max( 23 | constants.usernameMaxLen, 24 | `At most ${constants.usernameMaxLen} characters` 25 | ) 26 | .matches( 27 | constants.usernameRegex, 28 | "Only English letters, digits and underscores allowed" 29 | ), 30 | name: yup 31 | .string() 32 | .trim() 33 | .required("Required") 34 | .max(constants.nameMaxLen, `At most ${constants.nameMaxLen} characters`), 35 | password: yup 36 | .string() 37 | .trim() 38 | .required("Required") 39 | .min( 40 | constants.passwordMinLen, 41 | `At least ${constants.passwordMinLen} characters` 42 | ), 43 | confpass: yup 44 | .string() 45 | .trim() 46 | .required("Required") 47 | .oneOf([yup.ref("password"), null], "Passwords do not match") 48 | }); 49 | 50 | export default function SignUp({ setAuthType, handleModalClose }: FormProps) { 51 | const dispatch = useAppDispatch(); 52 | const authStatus = useAppSelector(state => state.auth.status); 53 | 54 | const formik = useFormik({ 55 | initialValues: { 56 | email: "", 57 | username: "", 58 | name: "", 59 | password: "", 60 | confpass: "" 61 | }, 62 | validationSchema, 63 | onSubmit: async values => { 64 | try { 65 | await dispatch(signupThunk(values)).unwrap(); 66 | handleModalClose(); 67 | } catch (err: any) { 68 | dispatch( 69 | notificationActions.showNotification({ 70 | type: "error", 71 | message: err.message 72 | }) 73 | ); 74 | } 75 | } 76 | }); 77 | 78 | return ( 79 | <> 80 |

Sign up

81 |
82 |

Sign up via username

83 | 90 | 98 | 105 | 113 | 121 | 134 |
135 |
136 | Already have an account? 137 | setAuthType("login")}> Log In 138 |
139 | 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /src/pc/pages/upload/upload-page.scss: -------------------------------------------------------------------------------- 1 | $preview-height: 458px; 2 | $preview-width: 260px; 3 | 4 | .upload-page-container { 5 | .box-backdrop { 6 | z-index: 11; 7 | } 8 | 9 | .progress-box { 10 | display: flex; 11 | flex-direction: column; 12 | gap: 8px; 13 | position: fixed; 14 | top: 50%; 15 | left: 50%; 16 | width: 350px; 17 | transform: translate(-50%, -50%); 18 | z-index: 12; 19 | background: var(--clr-bg-elevation-2, var(--clr-background)); 20 | box-shadow: var(--shadows, 0 0 5px 5px var(--clr-shadow)); 21 | border-radius: 8px; 22 | padding: 16px 24px; 23 | text-align: center; 24 | 25 | h3 { 26 | font-weight: 500; 27 | font-size: 1.3rem; 28 | margin-bottom: 8px; 29 | } 30 | 31 | h5 { 32 | font-size: inherit; 33 | display: flex; 34 | align-items: baseline; 35 | gap: 5px; 36 | } 37 | 38 | p { 39 | font-weight: 600; 40 | font-size: 1.2rem; 41 | } 42 | 43 | span { 44 | font-size: 0.8rem; 45 | } 46 | 47 | button { 48 | margin-top: 4px; 49 | border: none; 50 | align-self: center; 51 | background: none; 52 | font-weight: 400; 53 | color: var(--clr-text); 54 | 55 | &:disabled { 56 | color: rgba(var(--clr-secondary-values), 0.6); 57 | } 58 | } 59 | } 60 | 61 | .card { 62 | background: var(--clr-bg-elevation-1, var(--clr-background)); 63 | box-shadow: var(--shadows, var(--clr-shadow) 0px 2px 8px); 64 | border-radius: 8px; 65 | margin-top: 16px; 66 | padding: 24px 56px 80px; 67 | margin-bottom: 24px; 68 | 69 | header { 70 | h1 { 71 | font-size: 1.4rem; 72 | font-weight: 600; 73 | } 74 | 75 | h4 { 76 | margin-top: 4px; 77 | color: rgba(var(--clr-secondary-values), 0.6); 78 | } 79 | } 80 | 81 | .card-body { 82 | margin-top: 42px; 83 | display: flex; 84 | gap: 24px; 85 | 86 | .video-portion { 87 | display: flex; 88 | flex-direction: column; 89 | align-items: center; 90 | justify-content: center; 91 | gap: 12px; 92 | border: 2px dashed rgba(var(--clr-secondary-values), 0.25); 93 | border-radius: 8px; 94 | width: $preview-width; 95 | height: $preview-height; 96 | cursor: pointer; 97 | transition: border-color 0.25s ease-out, background-color 0.25s ease-out; 98 | overflow: hidden; 99 | 100 | &:hover { 101 | border-color: var(--clr-primary); 102 | background: rgba(var(--clr-secondary-values), 0.03); 103 | } 104 | 105 | video { 106 | width: 100%; 107 | height: 100%; 108 | object-fit: contain; 109 | } 110 | 111 | input { 112 | display: none; 113 | } 114 | 115 | i { 116 | font-size: 2rem; 117 | color: var(--clr-primary); 118 | } 119 | 120 | h4 { 121 | font-weight: 600; 122 | font-size: 1rem; 123 | } 124 | 125 | p { 126 | display: flex; 127 | flex-direction: column; 128 | align-items: center; 129 | justify-content: center; 130 | gap: 12px; 131 | margin-top: 12px; 132 | font-size: 0.85rem; 133 | font-weight: 500; 134 | color: rgba(var(--clr-secondary-values), 0.5); 135 | } 136 | } 137 | 138 | .description-portion { 139 | display: flex; 140 | flex-direction: column; 141 | gap: 20px; 142 | width: 100%; 143 | 144 | .form-group { 145 | display: flex; 146 | flex-direction: column; 147 | gap: 4px; 148 | 149 | h5 { 150 | display: flex; 151 | flex-direction: column; 152 | 153 | span { 154 | display: inline-block; 155 | margin: 2px 0; 156 | color: rgba(var(--clr-secondary-values), 0.8); 157 | } 158 | } 159 | 160 | label { 161 | font-weight: 600; 162 | font-size: 1rem; 163 | } 164 | 165 | .input { 166 | background: none; 167 | border: 1px solid var(--clr-border); 168 | 169 | &.error { 170 | border: 1px solid red; 171 | } 172 | } 173 | } 174 | 175 | button { 176 | align-self: flex-start; 177 | padding: 12px 56px; 178 | margin-top: 1rem; 179 | } 180 | } 181 | } 182 | } 183 | 184 | .upload-spinner { 185 | --wrapper-padding: 4px; 186 | --circle-size: 16px; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/common/api/video.ts: -------------------------------------------------------------------------------- 1 | import io from "socket.io-client"; 2 | 3 | import { apiClient, baseURL } from "."; 4 | import { VideoQuery } from "../types"; 5 | 6 | const videoURL = "/video"; 7 | 8 | interface NonFormData { 9 | caption: string; 10 | music: string; 11 | tags: string; 12 | username: string; 13 | filename?: string; 14 | } 15 | 16 | export async function createVideo( 17 | formData: FormData, 18 | nonFormData: NonFormData, 19 | progressFn: (type: "upload" | "compress", e?: any) => void, 20 | completeFn: (videoId: string) => void, 21 | errFn: (err: Error) => void 22 | ) { 23 | nonFormData.filename = ( 24 | await apiClient.post(videoURL + "/create", formData, { 25 | headers: { 26 | "Content-Type": "multipart/form-data" 27 | }, 28 | timeout: 0, 29 | onUploadProgress: e => 30 | progressFn("upload", Math.round((e.loaded * 100) / e.total)) 31 | }) 32 | ).data.filename; 33 | 34 | const socket = io(baseURL); 35 | 36 | socket.emit("finaliseFile", nonFormData); 37 | socket.on("compressionProgress", compData => 38 | progressFn("compress", compData) 39 | ); 40 | socket.on("compressionComplete", compData => { 41 | completeFn(compData.videoId); 42 | socket.disconnect(); 43 | }); 44 | socket.on("compressionError", err => { 45 | errFn(err); 46 | socket.disconnect(); 47 | }); 48 | 49 | return socket; 50 | } 51 | 52 | const params: VideoQuery = { 53 | uploader: "1", 54 | caption: "1", 55 | music: "1", 56 | shares: "1", 57 | views: "1", 58 | createdAt: "1", 59 | likes: "1", 60 | tags: "1", 61 | comments: "num" 62 | }; 63 | export const getVideo = (id: string, username?: string | null) => 64 | apiClient.get(videoURL + "/" + id, { params: { ...params, username } }); 65 | 66 | export const getCustom = (id: string, p: VideoQuery) => 67 | apiClient.get(videoURL + "/" + id, { params: p }); 68 | 69 | export const getVidComments = (id: string, username?: string | null) => 70 | apiClient.get(videoURL + "/" + id, { 71 | params: { comments: "list", username } 72 | }); 73 | 74 | export const deleteVideo = (id: string, username: string, token: string) => 75 | apiClient.delete(videoURL + "/" + id, { 76 | data: { username, token } 77 | }); 78 | 79 | export const likeVideo = (username: string, id: string, token: string) => 80 | apiClient.post(videoURL + "/like", { username, videoId: id, token }); 81 | 82 | export const postComment = ( 83 | username: string, 84 | comment: string, 85 | videoId: string, 86 | token: string 87 | ) => 88 | apiClient.post(videoURL + "/comment", { username, comment, videoId, token }); 89 | 90 | export const likeComment = ( 91 | videoId: string, 92 | commentId: string, 93 | username: string, 94 | token: string 95 | ) => 96 | apiClient.post(videoURL + "/likeComment", { 97 | videoId, 98 | commentId, 99 | username, 100 | token 101 | }); 102 | 103 | export const deleteComment = ( 104 | commentId: string, 105 | videoId: string, 106 | username: string, 107 | token: string 108 | ) => 109 | apiClient.delete(videoURL + "/comment", { 110 | data: { commentId, videoId, username, token } 111 | }); 112 | 113 | export const reply = ( 114 | comment: string, 115 | commentId: string, 116 | videoId: string, 117 | username: string, 118 | token: string 119 | ) => 120 | apiClient.post(videoURL + "/reply", { 121 | comment, 122 | commentId, 123 | videoId, 124 | username, 125 | token 126 | }); 127 | 128 | export const deleteReply = ( 129 | videoId: string, 130 | commentId: string, 131 | replyId: string, 132 | username: string, 133 | token: string 134 | ) => 135 | apiClient.delete(videoURL + "/reply", { 136 | data: { videoId, commentId, replyId, username, token } 137 | }); 138 | 139 | export const getReplies = ( 140 | videoId: string, 141 | commentId: string, 142 | username?: string | null 143 | ) => 144 | apiClient.get(videoURL + "/getReplies", { 145 | params: { videoId, commentId, username } 146 | }); 147 | 148 | export const likeReply = ( 149 | videoId: string, 150 | commentId: string, 151 | replyId: string, 152 | username: string, 153 | token: string 154 | ) => 155 | apiClient.post(videoURL + "/likeReply", { 156 | videoId, 157 | commentId, 158 | replyId, 159 | username, 160 | token 161 | }); 162 | 163 | export const share = (videoId: string) => 164 | apiClient.post(videoURL + "/share", { videoId }); 165 | -------------------------------------------------------------------------------- /src/mobile/components/comments-modal/Reply.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import "./comments-modal.scss"; 4 | import { CommentData } from "../../../common/types"; 5 | import constants from "../../../common/constants"; 6 | import { convertToDate, joinClasses } from "../../../common/utils"; 7 | import { deleteReply, likeReply } from "../../../common/api/video"; 8 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 9 | import { errorNotification } from "../../helpers/error-notification"; 10 | import { LikesInfo } from "./Comment"; 11 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 12 | import Dropdown from "../../../common/components/dropdown"; 13 | 14 | interface Props extends CommentData { 15 | uploader: string; 16 | commentId: string; 17 | videoId: string; 18 | setReplies: React.Dispatch>; 19 | fetchReplies: () => void; 20 | setRepliesNum: React.Dispatch>; 21 | } 22 | 23 | export default function Reply(props: Props) { 24 | const dispatch = useAppDispatch(); 25 | const { username, token } = useAppSelector(state => state.auth); 26 | const [likesInfo, setLikesInfo] = useState({ 27 | hasLiked: props.hasLiked!, 28 | likesNum: props.likes! 29 | }); 30 | const [showOptions, setShowOptions] = useState(false); 31 | 32 | function likeRep() { 33 | errorNotification( 34 | async () => { 35 | const res = await likeReply( 36 | props.videoId, 37 | props.commentId!, 38 | props.replyId!, 39 | username!, 40 | token! 41 | ); 42 | setLikesInfo(prev => ({ 43 | hasLiked: res.data.liked, 44 | likesNum: prev.likesNum + (res.data.liked ? 1 : -1) 45 | })); 46 | }, 47 | dispatch, 48 | null, 49 | "Couldn't like comment:" 50 | ); 51 | } 52 | 53 | function delRep() { 54 | errorNotification( 55 | async () => { 56 | await deleteReply( 57 | props.videoId, 58 | props.commentId, 59 | props.replyId!, 60 | username!, 61 | token! 62 | ); 63 | dispatch( 64 | notificationActions.showNotification({ 65 | type: "success", 66 | message: "Reply deleted" 67 | }) 68 | ); 69 | props.setRepliesNum(prev => prev - 1); 70 | props.setReplies(null); 71 | props.fetchReplies(); 72 | }, 73 | dispatch, 74 | null, 75 | "Couldn't delete reply:" 76 | ); 77 | } 78 | 79 | return ( 80 |
81 |
82 | {props.postedBy!.name} 86 |
87 |
88 |
89 |
90 |

91 | {props.postedBy!.name} 92 | {props.uploader === props.postedBy!.username && ( 93 | • Creator 94 | )} 95 |

96 |

{props.comment}

97 |
98 | {convertToDate(props.createdAt!)} 99 |
100 |
101 |
102 | {props.postedBy!.username === username && ( 103 |
104 | { 107 | setShowOptions(true); 108 | e.stopPropagation(); // required because of createPortal 109 | }} 110 | /> 111 | {showOptions && ( 112 | 116 | 117 | Delete 118 | 119 | 120 | )} 121 |
122 | )} 123 |
124 | 132 | {likesInfo.likesNum} 133 |
134 |
135 |
136 |
137 |
138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /src/pc/pages/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useCallback } from "react"; 2 | import { useLocation } from "react-router-dom"; 3 | 4 | import "./search.scss"; 5 | import PageWithSidebar from "../../components/page-with-sidebar"; 6 | import AccountCard from "../../components/search-results/AccountCard"; 7 | import VideoCard from "../../components/search-results/VideoCard"; 8 | import { useAppDispatch, useAppSelector } from "../../../common/store"; 9 | import { search } from "../../../common/api/feed"; 10 | import { notificationActions } from "../../../common/store/slices/notification-slice"; 11 | import { UserData, VideoData } from "../../../common/types"; 12 | import { joinClasses } from "../../../common/utils"; 13 | import LoadingSpinner from "../../../common/components/loading-spinner"; 14 | import { searchActions } from "../../store/slices/search-slice"; 15 | 16 | type sendType = "accounts" | "videos"; 17 | let prevQuery: string | null = null; 18 | 19 | export default function Search() { 20 | const params = new URLSearchParams(useLocation().search); 21 | const query = params.get("query"); 22 | const send = (params.get("send") as sendType) || "accounts"; 23 | const storeQuery = useAppSelector(state => state.pc.search.query); 24 | const dispatch = useAppDispatch(); 25 | const [accounts, setAccounts] = useState(null); 26 | const [videos, setVideos] = useState(null); 27 | const [activeTab, setActiveTab] = useState(send); 28 | 29 | const fetchResults = useCallback( 30 | async (send: "accounts" | "videos") => { 31 | try { 32 | if (!query) throw new Error("Invalid search query"); 33 | const res = await search(query, send); 34 | if (send === "accounts") setAccounts(res.data.accounts); 35 | else setVideos(res.data.videos); 36 | } catch (err: any) { 37 | dispatch( 38 | notificationActions.showNotification({ 39 | type: "error", 40 | message: err.message 41 | }) 42 | ); 43 | } 44 | }, 45 | [dispatch, query] 46 | ); 47 | 48 | useEffect(() => { 49 | fetchResults(send); 50 | }, [fetchResults, send]); 51 | 52 | // need this only when the component is mounted 53 | useEffect(() => { 54 | if (query !== storeQuery) dispatch(searchActions.putQuery(query)); 55 | // eslint-disable-next-line react-hooks/exhaustive-deps 56 | }, [dispatch]); 57 | 58 | useEffect(() => { 59 | return () => { 60 | dispatch(searchActions.dropQuery()); 61 | }; 62 | }, [dispatch]); 63 | 64 | useEffect(() => { 65 | if (prevQuery !== query) { 66 | setAccounts(null); 67 | setVideos(null); 68 | } 69 | 70 | return () => { 71 | prevQuery = query; 72 | }; 73 | }, [query]); 74 | 75 | const handleNavClick = useCallback( 76 | (send: sendType) => { 77 | if (send === "videos") { 78 | if (!videos) fetchResults("videos"); 79 | setActiveTab("videos"); 80 | } else { 81 | if (!accounts) fetchResults("accounts"); 82 | setActiveTab("accounts"); 83 | } 84 | }, 85 | [fetchResults, videos, accounts] 86 | ); 87 | 88 | return ( 89 | 90 |
91 | 111 | {activeTab === "accounts" ? ( 112 |
113 | {!accounts ? ( 114 | 115 | ) : accounts.length === 0 ? ( 116 |
117 | No accounts match your query "{query}". 118 |
119 | ) : ( 120 | accounts.map((acc, i) => ) 121 | )} 122 |
123 | ) : ( 124 |
125 | {!videos ? ( 126 | 127 | ) : videos.length === 0 ? ( 128 |
129 | No videos match your query "{query}". 130 |
131 | ) : ( 132 | videos.map((vid, i) => ) 133 | )} 134 |
135 | )} 136 |
137 |
138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /src/pc/components/header/header.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../index.scss"; 2 | 3 | $sidebar-width: 356px; 4 | $profile-icon-size: 32px; 5 | $notification-dd-height: 640px; 6 | $notification-dd-width: 376px; 7 | 8 | .header-wrapper { 9 | height: $header-height; 10 | } 11 | 12 | .header-container { 13 | box-shadow: 0 1px 1px var(--clr-shadow); 14 | position: fixed; 15 | top: 0; 16 | left: 0; 17 | right: 0; 18 | z-index: 10; 19 | background: var(--clr-background); 20 | } 21 | 22 | .header { 23 | height: $header-height; 24 | display: flex; 25 | align-items: center; 26 | justify-content: space-between; 27 | 28 | li { 29 | height: calc(#{$header-height} / 1.75); 30 | display: flex; 31 | align-items: center; 32 | } 33 | 34 | .logo { 35 | height: 100%; 36 | filter: var(--logo-filter, none); 37 | } 38 | 39 | .search-bar { 40 | background: rgba(var(--clr-secondary-values), 0.06); 41 | width: $sidebar-width; 42 | border-radius: 92px; 43 | display: flex; 44 | align-items: center; 45 | color: var(--clr-text); 46 | 47 | input { 48 | padding: 12px 16px; 49 | font-size: 0.9rem; 50 | caret-color: var(--clr-primary); 51 | width: 90%; 52 | } 53 | 54 | span { 55 | display: block; 56 | background: rgba(var(--clr-secondary-values), 0.12); 57 | margin: -3px 0; 58 | width: 1px; 59 | height: 28px; 60 | } 61 | 62 | button { 63 | padding: 16px; 64 | border-radius: 0 92px 92px 0; 65 | display: flex; 66 | justify-content: center; 67 | align-items: center; 68 | color: rgba(var(--clr-secondary-values), 0.5); 69 | cursor: pointer; 70 | background: none; 71 | font-size: 1rem; 72 | 73 | &:hover { 74 | background: rgba(var(--clr-secondary-values), 0.03); 75 | } 76 | } 77 | } 78 | 79 | .buttons { 80 | display: flex; 81 | gap: 16px; 82 | font-weight: 600; 83 | font-size: 16px; 84 | 85 | span { 86 | transition: box-shadow 0.2s ease-out; 87 | cursor: pointer; 88 | user-select: none; 89 | 90 | &:hover { 91 | box-shadow: 0 2px 0 rgba(var(--clr-shadow-values), 0.5); 92 | } 93 | } 94 | 95 | button { 96 | padding: 8px 28px; 97 | } 98 | } 99 | .icons { 100 | display: flex; 101 | gap: 24px; 102 | font-weight: 600; 103 | font-size: 1.2rem; 104 | position: relative; 105 | 106 | i { 107 | color: rgba(var(--clr-secondary-values), 0.85); 108 | } 109 | 110 | .icon { 111 | cursor: pointer; 112 | } 113 | 114 | .profile-icon { 115 | width: $profile-icon-size; 116 | height: $profile-icon-size; 117 | } 118 | } 119 | 120 | .options-dropdown { 121 | position: absolute; 122 | right: 0; 123 | z-index: 11; 124 | transform: translateY(110%); 125 | 126 | & > * { 127 | display: flex; 128 | align-items: center; 129 | gap: 12px; 130 | padding: 8px 16px; 131 | } 132 | 133 | i { 134 | width: 14px; 135 | } 136 | } 137 | } 138 | 139 | .inbox { 140 | position: relative; 141 | 142 | .dot { 143 | position: absolute; 144 | top: 0; 145 | left: 100%; 146 | background-color: red; 147 | width: 8px; 148 | height: 8px; 149 | border-radius: 50%; 150 | } 151 | 152 | .notif-spinner { 153 | --circle-size: 12px; 154 | --wrapper-padding: 8px; 155 | } 156 | } 157 | 158 | .inbox { 159 | .inbox-card { 160 | position: absolute; 161 | right: 0; 162 | top: 130%; 163 | z-index: 12; 164 | width: $notification-dd-width; 165 | height: $notification-dd-height; 166 | overflow: auto; 167 | 168 | h1 { 169 | font-size: 1.3rem; 170 | font-weight: 600; 171 | padding: 8px 0 8px 16px; 172 | } 173 | 174 | .notif-container { 175 | display: flex; 176 | padding: 10px 16px; 177 | 178 | &.unread { 179 | background-color: rgba(var(--clr-secondary-values), 0.05); 180 | } 181 | 182 | .rounded-photo { 183 | width: 48px; 184 | height: 48px; 185 | flex-shrink: 0; 186 | } 187 | 188 | .content { 189 | padding: 0 12px; 190 | font-size: 0.8rem; 191 | line-height: 1.12rem; 192 | font-weight: 400; 193 | width: 100%; 194 | 195 | h4 { 196 | font-weight: 600; 197 | 198 | &:hover { 199 | text-decoration: underline; 200 | } 201 | } 202 | 203 | span { 204 | font-size: 0.75rem; 205 | color: rgba(var(--clr-secondary-values), 0.75); 206 | } 207 | } 208 | 209 | .video-container { 210 | max-width: 42px; 211 | height: 56px; 212 | flex: 1 0 42px; 213 | align-self: center; 214 | 215 | video { 216 | width: 100%; 217 | height: 100%; 218 | object-fit: cover; 219 | } 220 | } 221 | 222 | .delete-btn { 223 | margin-left: 20px; 224 | align-self: center; 225 | font-size: 1rem; 226 | cursor: default; 227 | 228 | i { 229 | color: rgba(var(--clr-shadow-values), 0.5); 230 | } 231 | } 232 | } 233 | } 234 | } 235 | --------------------------------------------------------------------------------