├── apps
├── .gitkeep
├── bloggo
│ ├── public
│ │ ├── .gitkeep
│ │ ├── facebook.svg
│ │ ├── twitter.svg
│ │ └── google.svg
│ ├── styles
│ │ ├── tailwind.css
│ │ ├── _fonts.scss
│ │ ├── _polyfills.scss
│ │ ├── _theme-variables.scss
│ │ └── styles.scss
│ ├── index.d.ts
│ ├── next-env.d.ts
│ ├── postcss.config.js
│ ├── pages
│ │ ├── portal
│ │ │ ├── index.tsx
│ │ │ ├── [slug].tsx
│ │ │ ├── liked-posts.tsx
│ │ │ ├── posts.tsx
│ │ │ ├── edit-profile.tsx
│ │ │ └── create-post.tsx
│ │ ├── forgot-password.tsx
│ │ ├── 404.tsx
│ │ ├── _app.tsx
│ │ ├── index.tsx
│ │ ├── [username]
│ │ │ └── [slug].tsx
│ │ └── onboarding.tsx
│ ├── .env.example
│ ├── jest.config.ts
│ ├── tsconfig.spec.json
│ ├── tsconfig.json
│ ├── next.config.js
│ ├── .eslintrc.json
│ ├── project.json
│ └── tailwind.config.js
└── bloggo-e2e
│ ├── src
│ ├── support
│ │ ├── app.po.ts
│ │ ├── index.ts
│ │ └── commands.ts
│ ├── fixtures
│ │ └── example.json
│ └── integration
│ │ └── app.spec.ts
│ ├── tsconfig.json
│ ├── .eslintrc.json
│ ├── cypress.json
│ └── project.json
├── libs
├── .gitkeep
├── ui
│ ├── src
│ │ ├── lib
│ │ │ ├── badge
│ │ │ │ ├── index.tsx
│ │ │ │ ├── badge-list.component.tsx
│ │ │ │ └── badge.component.tsx
│ │ │ ├── image
│ │ │ │ ├── index.tsx
│ │ │ │ ├── placeholders
│ │ │ │ │ ├── placeholder-large-h.png
│ │ │ │ │ ├── placeholder-large.png
│ │ │ │ │ ├── placeholder-large-dark-h.png
│ │ │ │ │ └── placeholder-large-dark.png
│ │ │ │ ├── image-inview-port.util.ts
│ │ │ │ └── image-container.component.tsx
│ │ │ ├── dark-mode-toggle
│ │ │ │ ├── index.tsx
│ │ │ │ ├── dark-mode-switch.component.tsx
│ │ │ │ └── dark-mode-toggle-container.component.tsx
│ │ │ ├── forms
│ │ │ │ ├── index.tsx
│ │ │ │ ├── form-select.component.tsx
│ │ │ │ └── form-feedback.component.tsx
│ │ │ ├── navbar
│ │ │ │ ├── index.tsx
│ │ │ │ ├── navbar.module.scss
│ │ │ │ ├── navbar-action-button.component.tsx
│ │ │ │ ├── navigation.component.tsx
│ │ │ │ └── navbar.component.tsx
│ │ │ ├── portal
│ │ │ │ ├── index.tsx
│ │ │ │ ├── portal-navbar.component.tsx
│ │ │ │ └── user-posts-list.component.tsx
│ │ │ ├── sliders
│ │ │ │ ├── index.tsx
│ │ │ │ └── newest-authors-slider.component.tsx
│ │ │ ├── buttons
│ │ │ │ ├── index.tsx
│ │ │ │ ├── share-post-button-container-component.tsx
│ │ │ │ ├── bookmark-post-button.component.tsx
│ │ │ │ ├── next-prev-buttons.component.tsx
│ │ │ │ ├── comment-on-post-button.component.tsx
│ │ │ │ ├── button.component.tsx
│ │ │ │ └── like-post-button.component.tsx
│ │ │ ├── loader.component.tsx
│ │ │ ├── post
│ │ │ │ ├── index.tsx
│ │ │ │ ├── post-thumbnail.component.tsx
│ │ │ │ ├── post-content.component.tsx
│ │ │ │ ├── post-preview-card-author.component.tsx
│ │ │ │ ├── post-preview-card-action-buttons.component.tsx
│ │ │ │ ├── post-edit.component.tsx
│ │ │ │ ├── post-entry-metadata.component.tsx
│ │ │ │ ├── post-preview-card.component.tsx
│ │ │ │ ├── post-filter-list-box.component.tsx
│ │ │ │ └── latest-posts-preview.component.tsx
│ │ │ ├── logo.component.tsx
│ │ │ ├── markdown.component.tsx
│ │ │ ├── label.component.tsx
│ │ │ ├── auth-check.component.tsx
│ │ │ ├── page-heading.component.tsx
│ │ │ ├── link.component.tsx
│ │ │ ├── meta-tags.component.tsx
│ │ │ ├── input.component.tsx
│ │ │ ├── social-media-links.component.tsx
│ │ │ ├── section-heading.component.tsx
│ │ │ ├── avatar.component.tsx
│ │ │ ├── search-dropdown.component.tsx
│ │ │ ├── footer.component.tsx
│ │ │ ├── menu-bar.component.tsx
│ │ │ ├── app-layout.component.tsx
│ │ │ ├── toast.component.tsx
│ │ │ ├── cards
│ │ │ │ ├── author-card.component.tsx
│ │ │ │ ├── small-post-preview-card.component.tsx
│ │ │ │ └── large-post-preview-card.component.tsx
│ │ │ ├── image-uploader.component.tsx
│ │ │ └── header.component.tsx
│ │ └── index.ts
│ ├── README.md
│ ├── .babelrc
│ ├── jest.config.ts
│ ├── .eslintrc.json
│ ├── tsconfig.spec.json
│ ├── tsconfig.lib.json
│ ├── tsconfig.json
│ └── project.json
└── redux
│ ├── src
│ ├── lib
│ │ ├── layout
│ │ │ ├── types.ts
│ │ │ └── index.ts
│ │ ├── firebase
│ │ │ ├── index.ts
│ │ │ ├── helpers.ts
│ │ │ ├── types.ts
│ │ │ ├── auth.ts
│ │ │ ├── firebase.ts
│ │ │ └── firestore-queries.ts
│ │ ├── hooks.ts
│ │ ├── user
│ │ │ ├── types.ts
│ │ │ └── index.ts
│ │ └── store.ts
│ └── index.ts
│ ├── .babelrc
│ ├── README.md
│ ├── tsconfig.json
│ ├── tsconfig.lib.json
│ ├── .eslintrc.json
│ ├── jest.config.ts
│ ├── tsconfig.spec.json
│ └── project.json
├── .eslintignore
├── babel.config.json
├── assets
└── bloggo.png
├── .prettierignore
├── jest.config.ts
├── firestore.rules
├── .vscode
└── extensions.json
├── .prettierrc
├── .editorconfig
├── storage.rules
├── tools
└── tsconfig.tools.json
├── tailwind-workspace-preset.js
├── tsconfig.base.json
├── jest.preset.js
├── firebase.json
├── .eslintrc.json
├── firestore.indexes.json
├── LICENSE
├── .gitignore
├── nx.json
├── package.json
└── README.md
/apps/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/libs/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/bloggo/public/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "babelrcRoots": ["*"]
3 | }
4 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/badge/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './badge-list.component';
2 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/image/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './image-container.component';
2 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/src/support/app.po.ts:
--------------------------------------------------------------------------------
1 | export const getGreeting = () => cy.get('h1');
2 |
--------------------------------------------------------------------------------
/assets/bloggo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/msanvarov/bloggo/HEAD/assets/bloggo.png
--------------------------------------------------------------------------------
/apps/bloggo/styles/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/layout/types.ts:
--------------------------------------------------------------------------------
1 | export interface ILayoutState {
2 | darkMode: boolean;
3 | }
4 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/dark-mode-toggle/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './dark-mode-toggle-container.component';
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Add files here to ignore them from prettier formatting
2 |
3 | /dist
4 | /coverage
5 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/forms/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './form-feedback.component';
2 | export * from './form-select.component';
3 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './navbar.component';
2 | export * from './navbar-action-button.component';
3 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/portal/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './portal-navbar.component';
2 | export * from './user-posts-list.component';
3 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/src/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io"
4 | }
5 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | const { getJestProjects } = require('@nx/jest');
2 |
3 | export default {
4 | projects: getJestProjects(),
5 | };
6 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/sliders/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './most-popular-posts-slider.component';
2 | export * from './newest-authors-slider.component';
3 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/image/placeholders/placeholder-large-h.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/msanvarov/bloggo/HEAD/libs/ui/src/lib/image/placeholders/placeholder-large-h.png
--------------------------------------------------------------------------------
/libs/ui/src/lib/image/placeholders/placeholder-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/msanvarov/bloggo/HEAD/libs/ui/src/lib/image/placeholders/placeholder-large.png
--------------------------------------------------------------------------------
/libs/redux/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@nrwl/js/babel",
5 | {
6 | "useBuiltIns": "usage"
7 | }
8 | ]
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/buttons/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './like-post-button.component';
2 | export * from './bookmark-post-button.component';
3 | export * from './button.component';
4 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/image/placeholders/placeholder-large-dark-h.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/msanvarov/bloggo/HEAD/libs/ui/src/lib/image/placeholders/placeholder-large-dark-h.png
--------------------------------------------------------------------------------
/libs/ui/src/lib/image/placeholders/placeholder-large-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/msanvarov/bloggo/HEAD/libs/ui/src/lib/image/placeholders/placeholder-large-dark.png
--------------------------------------------------------------------------------
/libs/redux/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './lib/store';
2 | export * from './lib/hooks';
3 |
4 | export * from './lib/firebase';
5 | export * from './lib/user';
6 | export * from './lib/layout';
7 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/firebase/index.ts:
--------------------------------------------------------------------------------
1 | export * from './firebase';
2 | export * from './auth';
3 | export * from './firestore-queries';
4 | export * from './types';
5 | export * from './helpers';
6 |
--------------------------------------------------------------------------------
/libs/ui/README.md:
--------------------------------------------------------------------------------
1 | # ui
2 |
3 | This library was generated with [Nx](https://nx.dev).
4 |
5 | ## Running unit tests
6 |
7 | Run `nx test ui` to execute the unit tests via [Jest](https://jestjs.io).
8 |
--------------------------------------------------------------------------------
/firestore.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service cloud.firestore {
3 | match /databases/{database}/documents {
4 | match /{document=**} {
5 | allow read, write: if true;
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "nrwl.angular-console",
4 | "esbenp.prettier-vscode",
5 | "firsttris.vscode-jest-runner",
6 | "dbaeumer.vscode-eslint"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/apps/bloggo/index.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | declare module '*.svg' {
3 | const content: any;
4 | export const ReactComponent: any;
5 | export default content;
6 | }
7 |
--------------------------------------------------------------------------------
/libs/redux/README.md:
--------------------------------------------------------------------------------
1 | # redux
2 |
3 | This library was generated with [Nx](https://nx.dev).
4 |
5 | ## Running unit tests
6 |
7 | Run `nx test redux` to execute the unit tests via [Jest](https://jestjs.io).
8 |
--------------------------------------------------------------------------------
/libs/ui/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@nx/react/babel",
5 | {
6 | "runtime": "automatic",
7 | "useBuiltIns": "usage"
8 | }
9 | ]
10 | ],
11 | "plugins": []
12 | }
13 |
--------------------------------------------------------------------------------
/apps/bloggo/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/navbar/navbar.module.scss:
--------------------------------------------------------------------------------
1 | .main-nav {
2 | &.not-on-top {
3 | @apply bg-white dark:bg-neutral-900 backdrop-blur-2xl bg-opacity-70 dark:bg-opacity-60 shadow-sm dark:border-b dark:border-neutral-700;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/apps/bloggo/postcss.config.js:
--------------------------------------------------------------------------------
1 | const { join } = require('path');
2 |
3 | module.exports = {
4 | plugins: {
5 | tailwindcss: {
6 | config: join(__dirname, 'tailwind.config.js'),
7 | },
8 | autoprefixer: {},
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "trailingComma": "all",
4 | "singleQuote": true,
5 | "semi": true,
6 | "importOrder": ["^@bloggo/(.*)$", "^[./]"],
7 | "importOrderSeparation": true,
8 | "importOrderSortSpecifiers": true
9 | }
10 |
--------------------------------------------------------------------------------
/libs/redux/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "files": [],
4 | "include": [],
5 | "references": [
6 | {
7 | "path": "./tsconfig.lib.json"
8 | },
9 | {
10 | "path": "./tsconfig.spec.json"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/loader.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const Loader: React.FC = () => {
4 | return (
5 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "sourceMap": false,
5 | "outDir": "../../dist/out-tsc",
6 | "allowJs": true,
7 | "types": ["cypress", "node"]
8 | },
9 | "include": ["src/**/*.ts", "src/**/*.js"]
10 | }
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Editor configuration, see http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | max_line_length = off
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/hooks.ts:
--------------------------------------------------------------------------------
1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
2 |
3 | import type { AppDispatch, AppState } from './store';
4 |
5 | export const useAppDispatch = () => useDispatch();
6 | export const useAppSelector: TypedUseSelectorHook = useSelector;
7 |
--------------------------------------------------------------------------------
/libs/ui/jest.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | export default {
3 | displayName: 'ui',
4 | preset: '../../jest.preset.js',
5 | transform: {
6 | '^.+\\.[tj]sx?$': 'babel-jest',
7 | },
8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
9 | coverageDirectory: '../../coverage/libs/ui',
10 | };
11 |
--------------------------------------------------------------------------------
/storage.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service firebase.storage {
3 | match /b/{bucket}/o {
4 | match /{allPaths=**} {
5 | allow read: if request.auth!=null;
6 | }
7 | match /uploads/{uid}/{imageHash} {
8 | allow write: if resource == null || request.resource == null
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/tools/tsconfig.tools.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "../dist/out-tsc/tools",
5 | "rootDir": ".",
6 | "module": "commonjs",
7 | "target": "es5",
8 | "types": ["node"],
9 | "importHelpers": false
10 | },
11 | "include": ["**/*.ts"]
12 | }
13 |
--------------------------------------------------------------------------------
/apps/bloggo/styles/_fonts.scss:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
2 |
3 | html body {
4 | @apply font-body antialiased;
5 | }
6 |
7 | h1,
8 | h2,
9 | h3,
10 | h4 {
11 | @apply font-display;
12 | }
13 |
14 | :root {
15 | --font-display: Poppins;
16 | --font-body: Poppins;
17 | }
18 |
--------------------------------------------------------------------------------
/apps/bloggo/styles/_polyfills.scss:
--------------------------------------------------------------------------------
1 | /* Hide scrollbar for Chrome, Safari and Opera */
2 | .hiddenScrollbar::-webkit-scrollbar {
3 | display: none;
4 | }
5 |
6 | /* Hide scrollbar for IE, Edge and Firefox */
7 | .hiddenScrollbar {
8 | -ms-overflow-style: none; /* IE and Edge */
9 | scrollbar-width: none; /* Firefox */
10 | }
11 |
--------------------------------------------------------------------------------
/libs/redux/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "outDir": "../../dist/out-tsc",
6 | "declaration": true,
7 | "types": ["node"]
8 | },
9 | "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
10 | "include": ["**/*.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/portal/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Metatags } from '@bloggo/ui';
4 |
5 | const PortalIndexPage: React.FC = () => {
6 | return (
7 | <>
8 |
9 | Portal Index Page
10 | >
11 | );
12 | };
13 |
14 | export default PortalIndexPage;
15 |
--------------------------------------------------------------------------------
/tailwind-workspace-preset.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | theme: {
3 | extend: {},
4 | },
5 | variants: {
6 | extend: {},
7 | },
8 | plugins: [
9 | require('@tailwindcss/typography'),
10 | require('@tailwindcss/forms'),
11 | require('@tailwindcss/line-clamp'),
12 | require('@tailwindcss/aspect-ratio'),
13 | ],
14 | };
15 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/forgot-password.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Metatags } from '@bloggo/ui';
4 |
5 | const ForgotPasswordPage: React.FC = () => {
6 | return (
7 | <>
8 |
9 | Forgot Password Page
10 | >
11 | );
12 | };
13 |
14 | export default ForgotPasswordPage;
15 |
--------------------------------------------------------------------------------
/apps/bloggo/.env.example:
--------------------------------------------------------------------------------
1 | # FIREBASE
2 | FIREBASE_API_KEY=AIzaSyDNotinqdiNETddK4e_MA_rNsz1gkYKChw
3 | FIREBASE_AUTH_DOMAIN=bloggo-9fc7d.firebaseapp.com
4 | FIREBASE_PROJECT_ID=bloggo-9fc7d
5 | FIREBASE_STORAGE_BUCKET=bloggo-9fc7d.appspot.com
6 | FIREBASE_MESSAGING_SENDER_ID=424368452854
7 | FIREBASE_APP_ID=1:424368452854:web:361dd4c39219757306e8d7
8 | FIREBASE_MEASUREMENT_ID=G-D2F125TYVK
--------------------------------------------------------------------------------
/libs/redux/src/lib/user/types.ts:
--------------------------------------------------------------------------------
1 | export interface IUserState {
2 | user?: Partial | null;
3 | username?: string | null;
4 | }
5 |
6 | export interface IUser {
7 | uid: string;
8 | email: string | null;
9 | emailVerified: boolean;
10 | displayName: string | null;
11 | photoURL: string | null;
12 | createdAt?: string;
13 | lastLoginAt?: string;
14 | }
15 |
--------------------------------------------------------------------------------
/libs/redux/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["../../.eslintrc.json"],
3 | "ignorePatterns": ["!**/*"],
4 | "overrides": [
5 | {
6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7 | "rules": {}
8 | },
9 | {
10 | "files": ["*.ts", "*.tsx"],
11 | "rules": {}
12 | },
13 | {
14 | "files": ["*.js", "*.jsx"],
15 | "rules": {}
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/libs/ui/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["plugin:@nx/react", "../../.eslintrc.json"],
3 | "ignorePatterns": ["!**/*"],
4 | "overrides": [
5 | {
6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7 | "rules": {}
8 | },
9 | {
10 | "files": ["*.ts", "*.tsx"],
11 | "rules": {}
12 | },
13 | {
14 | "files": ["*.js", "*.jsx"],
15 | "rules": {}
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './post-filter-list-box.component';
2 | export * from './post-preview-card.component';
3 | export * from './latest-posts-preview.component';
4 | export * from './post-entry-metadata.component';
5 | export * from './post-preview-card-action-buttons.component';
6 | export * from './post-content.component';
7 | export * from './post-form.component';
8 | export * from './post-edit.component';
9 |
--------------------------------------------------------------------------------
/apps/bloggo/jest.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | export default {
3 | displayName: 'bloggo',
4 | preset: '../../jest.preset.js',
5 | transform: {
6 | '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
7 | '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/next/babel'] }],
8 | },
9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
10 | coverageDirectory: '../../coverage/apps/bloggo',
11 | };
12 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/logo.component.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import Link from 'next/link';
3 | import React from 'react';
4 |
5 | // TODO: use Next Image component for logo
6 | export const Logo: React.FC = () => {
7 | return (
8 |
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["plugin:cypress/recommended", "../../.eslintrc.json"],
3 | "ignorePatterns": ["!**/*"],
4 | "overrides": [
5 | {
6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7 | "rules": {}
8 | },
9 | {
10 | "files": ["src/plugins/index.js"],
11 | "rules": {
12 | "@typescript-eslint/no-var-requires": "off",
13 | "no-undef": "off"
14 | }
15 | }
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "fileServerFolder": ".",
3 | "fixturesFolder": "./src/fixtures",
4 | "integrationFolder": "./src/integration",
5 | "modifyObstructiveCode": false,
6 | "supportFile": "./src/support/index.ts",
7 | "pluginsFile": false,
8 | "video": true,
9 | "videosFolder": "../../dist/cypress/apps/bloggo-e2e/videos",
10 | "screenshotsFolder": "../../dist/cypress/apps/bloggo-e2e/screenshots",
11 | "chromeWebSecurity": false
12 | }
13 |
--------------------------------------------------------------------------------
/libs/redux/jest.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | export default {
3 | displayName: 'redux',
4 | preset: '../../jest.preset.js',
5 | globals: {},
6 | testEnvironment: 'node',
7 | transform: {
8 | '^.+\\.[tj]sx?$': [
9 | 'ts-jest',
10 | {
11 | tsconfig: '/tsconfig.spec.json',
12 | },
13 | ],
14 | },
15 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
16 | coverageDirectory: '../../coverage/libs/redux',
17 | };
18 |
--------------------------------------------------------------------------------
/apps/bloggo/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "module": "commonjs",
6 | "types": ["jest", "node"],
7 | "jsx": "react"
8 | },
9 | "include": [
10 | "**/*.test.ts",
11 | "**/*.spec.ts",
12 | "**/*.test.tsx",
13 | "**/*.spec.tsx",
14 | "**/*.test.js",
15 | "**/*.spec.js",
16 | "**/*.test.jsx",
17 | "**/*.spec.jsx",
18 | "**/*.d.ts"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/libs/redux/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "module": "commonjs",
6 | "types": ["jest", "node"]
7 | },
8 | "include": [
9 | "**/*.test.ts",
10 | "**/*.spec.ts",
11 | "**/*.test.tsx",
12 | "**/*.spec.tsx",
13 | "**/*.test.js",
14 | "**/*.spec.js",
15 | "**/*.test.jsx",
16 | "**/*.spec.jsx",
17 | "**/*.d.ts",
18 | "jest.config.ts"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/libs/ui/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "module": "commonjs",
6 | "types": ["jest", "node"]
7 | },
8 | "include": [
9 | "**/*.test.ts",
10 | "**/*.spec.ts",
11 | "**/*.test.tsx",
12 | "**/*.spec.tsx",
13 | "**/*.test.js",
14 | "**/*.spec.js",
15 | "**/*.test.jsx",
16 | "**/*.spec.jsx",
17 | "**/*.d.ts",
18 | "jest.config.ts"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/src/integration/app.spec.ts:
--------------------------------------------------------------------------------
1 | import { getGreeting } from '../support/app.po';
2 |
3 | describe('bloggo', () => {
4 | beforeEach(() => cy.visit('/'));
5 |
6 | it('should display welcome message', () => {
7 | // Custom command example, see `../support/commands.ts` file
8 | cy.login('my-email@something.com', 'myPassword');
9 |
10 | // Function helper example, see `../support/app.po.ts` file
11 | getGreeting().contains('Welcome bloggo');
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/markdown.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactMarkdown from 'react-markdown';
3 | import remarkGfm from 'remark-gfm';
4 |
5 | type MarkdownProps = {
6 | content: string;
7 | className?: string;
8 | };
9 |
10 | export const Markdown: React.FC = ({
11 | className = '',
12 | content,
13 | }) => {
14 | return (
15 |
16 | {content}
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/label.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { ReactNode } from 'react';
3 |
4 | type LabelProps = {
5 | className?: string;
6 | children?: ReactNode;
7 | };
8 |
9 | export const Label = ({ className = '', children }: LabelProps) => {
10 | return (
11 |
17 | {children}
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/apps/bloggo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "jsx": "preserve",
5 | "allowJs": true,
6 | "esModuleInterop": true,
7 | "allowSyntheticDefaultImports": true,
8 | "types": ["node", "jest"],
9 | "strict": false,
10 | "forceConsistentCasingInFileNames": true,
11 | "noEmit": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "incremental": true
15 | },
16 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "next-env.d.ts"],
17 | "exclude": ["node_modules", "jest.config.ts"]
18 | }
19 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/layout/index.ts:
--------------------------------------------------------------------------------
1 | import { PayloadAction, createSlice } from '@reduxjs/toolkit';
2 |
3 | const initialState = {
4 | darkMode: false,
5 | };
6 |
7 | const layoutSlice = createSlice({
8 | name: '@@layout',
9 | initialState,
10 | reducers: {
11 | toggleDarkMode: (state) => ({ darkMode: !state.darkMode }),
12 | setDarkMode: (state, action: PayloadAction) => {
13 | state.darkMode = action.payload;
14 | },
15 | },
16 | });
17 |
18 | export const { setDarkMode, toggleDarkMode } = layoutSlice.actions;
19 |
20 | export const layoutReducer = layoutSlice.reducer;
21 |
--------------------------------------------------------------------------------
/apps/bloggo/next.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const withNx = require('@nx/next/plugins/with-nx');
3 |
4 | /**
5 | * @type {import('@nx/next/plugins/with-nx').WithNxOptions}
6 | **/
7 | const nextConfig = {
8 | nx: {
9 | // Set this to true if you would like to to use SVGR
10 | // See: https://github.com/gregberge/svgr
11 | svgr: false,
12 | },
13 | images: {
14 | domains: [
15 | 'ui-avatars.com',
16 | 'lh3.googleusercontent.com',
17 | 'images.pexels.com',
18 | ],
19 | },
20 | };
21 |
22 | module.exports = withNx(nextConfig);
23 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/post-thumbnail.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { ImageContainer } from '../image';
4 |
5 | type PostThumbnailProps = {
6 | className?: string;
7 | media: {
8 | type: 'image' | 'video' | 'audio' | 'gallery';
9 | src: string;
10 | };
11 | };
12 |
13 | export const PostThumbnail: React.FC = ({
14 | className = 'w-full h-full',
15 | media,
16 | }) => {
17 | return (
18 |
19 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/libs/ui/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "types": ["node"]
6 | },
7 | "files": [
8 | "../../node_modules/@nx/react/typings/cssmodule.d.ts",
9 | "../../node_modules/@nx/react/typings/image.d.ts"
10 | ],
11 | "exclude": [
12 | "**/*.spec.ts",
13 | "**/*.test.ts",
14 | "**/*.spec.tsx",
15 | "**/*.test.tsx",
16 | "**/*.spec.js",
17 | "**/*.test.js",
18 | "**/*.spec.jsx",
19 | "**/*.test.jsx",
20 | "jest.config.ts"
21 | ],
22 | "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
23 | }
24 |
--------------------------------------------------------------------------------
/apps/bloggo/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "plugin:@nx/react-typescript",
4 | "../../.eslintrc.json",
5 | "next",
6 | "next/core-web-vitals"
7 | ],
8 | "ignorePatterns": ["!**/*"],
9 | "overrides": [
10 | {
11 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
12 | "rules": {
13 | "@next/next/no-html-link-for-pages": ["error", "apps/bloggo/pages"]
14 | }
15 | },
16 | {
17 | "files": ["*.ts", "*.tsx"],
18 | "rules": {}
19 | },
20 | {
21 | "files": ["*.js", "*.jsx"],
22 | "rules": {}
23 | }
24 | ],
25 | "env": {
26 | "jest": true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/firebase/helpers.ts:
--------------------------------------------------------------------------------
1 | import { DocumentSnapshot, Timestamp } from 'firebase/firestore';
2 |
3 | /**`
4 | * Converts a firestore document to JSON
5 | * @param {DocumentSnapshot} doc
6 | */
7 | export const docToJSON = (doc: DocumentSnapshot) => {
8 | const data = doc.data();
9 | if (data) {
10 | return {
11 | ...data,
12 | // TIMESTAMP is not JSON serializable. Must be converted to milliseconds
13 | createdAt: data.createdAt.toMillis(),
14 | updatedAt: data.updatedAt.toMillis(),
15 | };
16 | } else {
17 | return null;
18 | }
19 | };
20 |
21 | export const fromMillis = Timestamp.fromMillis;
22 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "rootDir": ".",
5 | "sourceMap": true,
6 | "declaration": false,
7 | "moduleResolution": "node",
8 | "emitDecoratorMetadata": true,
9 | "experimentalDecorators": true,
10 | "importHelpers": true,
11 | "target": "es2015",
12 | "module": "esnext",
13 | "lib": ["es2017", "dom"],
14 | "skipLibCheck": true,
15 | "skipDefaultLibCheck": true,
16 | "baseUrl": ".",
17 | "paths": {
18 | "@bloggo/redux": ["libs/redux/src/index.ts"],
19 | "@bloggo/ui": ["libs/ui/src/index.ts"]
20 | }
21 | },
22 | "exclude": ["node_modules", "tmp"]
23 | }
24 |
--------------------------------------------------------------------------------
/libs/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx",
5 | "allowJs": true,
6 | "esModuleInterop": true,
7 | "allowSyntheticDefaultImports": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "strict": true,
10 | "noImplicitOverride": true,
11 | "noPropertyAccessFromIndexSignature": true,
12 | "noImplicitReturns": true,
13 | "noFallthroughCasesInSwitch": true
14 | },
15 | "files": [],
16 | "include": [],
17 | "references": [
18 | {
19 | "path": "./tsconfig.lib.json"
20 | },
21 | {
22 | "path": "./tsconfig.spec.json"
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/src/support/index.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands';
18 |
--------------------------------------------------------------------------------
/libs/ui/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ui",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "libs/ui/src",
5 | "projectType": "library",
6 | "tags": [],
7 | "targets": {
8 | "lint": {
9 | "executor": "@nx/linter:eslint",
10 | "outputs": ["{options.outputFile}"],
11 | "options": {
12 | "lintFilePatterns": ["libs/ui/**/*.{ts,tsx,js,jsx}"]
13 | }
14 | },
15 | "test": {
16 | "executor": "@nx/jest:jest",
17 | "outputs": ["{workspaceRoot}/coverage/libs/ui"],
18 | "options": {
19 | "jestConfig": "libs/ui/jest.config.ts",
20 | "passWithNoTests": true
21 | }
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/libs/redux/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "libs/redux/src",
5 | "projectType": "library",
6 | "targets": {
7 | "lint": {
8 | "executor": "@nx/linter:eslint",
9 | "outputs": ["{options.outputFile}"],
10 | "options": {
11 | "lintFilePatterns": ["libs/redux/**/*.ts"]
12 | }
13 | },
14 | "test": {
15 | "executor": "@nx/jest:jest",
16 | "outputs": ["{workspaceRoot}/coverage/libs/redux"],
17 | "options": {
18 | "jestConfig": "libs/redux/jest.config.ts",
19 | "passWithNoTests": true
20 | }
21 | }
22 | },
23 | "tags": []
24 | }
25 |
--------------------------------------------------------------------------------
/jest.preset.js:
--------------------------------------------------------------------------------
1 | const nxPreset = require('@nx/jest/preset').default;
2 |
3 | module.exports = {
4 | ...nxPreset,
5 | /* TODO: Update to latest Jest snapshotFormat
6 | * By default Nx has kept the older style of Jest Snapshot formats
7 | * to prevent breaking of any existing tests with snapshots.
8 | * It's recommend you update to the latest format.
9 | * You can do this by removing snapshotFormat property
10 | * and running tests with --update-snapshot flag.
11 | * Example: "nx affected --targets=test --update-snapshot"
12 | * More info: https://jestjs.io/docs/upgrading-to-jest29#snapshot-format
13 | */
14 | snapshotFormat: { escapeString: true, printBasicPrototype: true },
15 | };
16 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/auth-check.component.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-no-useless-fragment */
2 | import { ReactNode } from 'react';
3 |
4 | import { AppState, useAppSelector } from '@bloggo/redux';
5 |
6 | import { Link } from './link.component';
7 |
8 | type AppCheckProps = {
9 | // managed by Next
10 | fallback?: ReactNode;
11 | children: ReactNode;
12 | };
13 |
14 | export const AuthCheck = ({ children, fallback }: AppCheckProps) => {
15 | const { user } = useAppSelector((state: AppState) => state.user);
16 | return user ? (
17 | <>{children}>
18 | ) : (
19 | <>{fallback}> || (
20 | Must be authenticated to access resource.
21 | )
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/user/index.ts:
--------------------------------------------------------------------------------
1 | import { PayloadAction, createSlice } from '@reduxjs/toolkit';
2 |
3 | import type { IUser, IUserState } from './types';
4 |
5 | const initialState: IUserState = {
6 | user: undefined,
7 | username: undefined,
8 | };
9 |
10 | const userSlice = createSlice({
11 | name: '@@user',
12 | initialState,
13 | reducers: {
14 | setUser: (state, action: PayloadAction) => {
15 | state.user = action.payload;
16 | },
17 | setUsername: (state, action: PayloadAction) => {
18 | state.username = action.payload;
19 | },
20 | },
21 | });
22 |
23 | export const { setUser, setUsername } = userSlice.actions;
24 |
25 | export const userReducer = userSlice.reducer;
26 |
--------------------------------------------------------------------------------
/apps/bloggo/public/facebook.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/page-heading.component.tsx:
--------------------------------------------------------------------------------
1 | import React, { HTMLAttributes } from 'react';
2 |
3 | interface PageHeadingProps extends HTMLAttributes {
4 | emoji?: string;
5 | className?: string;
6 | }
7 |
8 | export const PageHeading: React.FC = ({
9 | className = 'justify-center',
10 | emoji = '',
11 | children,
12 | ...rest
13 | }) => {
14 | return (
15 |
19 | {emoji && (
20 | {emoji}
21 | )}
22 | {children || `Heading`}
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "firestore": {
3 | "rules": "firestore.rules",
4 | "indexes": "firestore.indexes.json"
5 | },
6 | "hosting": {
7 | "public": "dist/apps/bloggo",
8 | "ignore": [
9 | "firebase.json",
10 | "**/.*",
11 | "**/node_modules/**"
12 | ]
13 | },
14 | "storage": {
15 | "rules": "storage.rules"
16 | },
17 | "emulators": {
18 | "auth": {
19 | "port": 9099
20 | },
21 | "functions": {
22 | "port": 5001
23 | },
24 | "firestore": {
25 | "port": 8080
26 | },
27 | "hosting": {
28 | "port": 5000
29 | },
30 | "pubsub": {
31 | "port": 8085
32 | },
33 | "storage": {
34 | "port": 9199
35 | },
36 | "ui": {
37 | "enabled": true,
38 | "port": 3000
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/post-content.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactMarkdown from 'react-markdown';
3 | import remarkGfm from 'remark-gfm';
4 |
5 | import { Markdown } from '../markdown.component';
6 |
7 | type PostContentProps = {
8 | content: string;
9 | };
10 |
11 | // TODO: Add disquis for comments
12 |
13 | export const PostContent: React.FC = ({ content }) => {
14 | return (
15 |
16 |
20 |
21 |
22 | {/* POST TAGS */}
23 | {/* AUTHOR DATA */}
24 | {/* COMMENT SECTION */}
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/portal/[slug].tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import React, { useState } from 'react';
3 |
4 | import { AppState, useAppSelector } from '@bloggo/redux';
5 | import { AppLayout, AuthCheck, Metatags, PostEdit } from '@bloggo/ui';
6 |
7 | const PortalEditPostPage: React.FC = () => {
8 | const router = useRouter();
9 | const { slug } = router.query;
10 | const { user } = useAppSelector((state: AppState) => state.user);
11 |
12 | return (
13 | <>
14 |
15 |
16 |
17 |
18 | {user?.uid && }
19 |
20 |
21 |
22 | >
23 | );
24 | };
25 |
26 | export default PortalEditPostPage;
27 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bloggo-e2e",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "apps/bloggo-e2e/src",
5 | "projectType": "application",
6 | "targets": {
7 | "e2e": {
8 | "executor": "@nx/cypress:cypress",
9 | "options": {
10 | "cypressConfig": "apps/bloggo-e2e/cypress.json",
11 | "devServerTarget": "bloggo:serve"
12 | },
13 | "configurations": {
14 | "production": {
15 | "devServerTarget": "bloggo:serve:production"
16 | }
17 | }
18 | },
19 | "lint": {
20 | "executor": "@nx/linter:eslint",
21 | "outputs": ["{options.outputFile}"],
22 | "options": {
23 | "lintFilePatterns": ["apps/bloggo-e2e/**/*.{js,ts}"]
24 | }
25 | }
26 | },
27 | "tags": [],
28 | "implicitDependencies": ["bloggo"]
29 | }
30 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "ignorePatterns": ["**/*"],
4 | "plugins": ["@nx"],
5 | "overrides": [
6 | {
7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
8 | "rules": {
9 | "@nx/enforce-module-boundaries": [
10 | "error",
11 | {
12 | "enforceBuildableLibDependency": true,
13 | "allow": [],
14 | "depConstraints": [
15 | {
16 | "sourceTag": "*",
17 | "onlyDependOnLibsWithTags": ["*"]
18 | }
19 | ]
20 | }
21 | ]
22 | }
23 | },
24 | {
25 | "files": ["*.ts", "*.tsx"],
26 | "extends": ["plugin:@nx/typescript"],
27 | "rules": {}
28 | },
29 | {
30 | "files": ["*.js", "*.jsx"],
31 | "extends": ["plugin:@nx/javascript"],
32 | "rules": {}
33 | }
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/navbar/navbar-action-button.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 |
4 | import { Link } from '../link.component';
5 |
6 | type NavbarActionButtonProps = {
7 | className?: string;
8 | href: string;
9 | icon: React.ReactNode;
10 | };
11 | export const NavbarActionButton: React.FC = ({
12 | className,
13 | href,
14 | icon,
15 | }) => {
16 | return (
17 |
25 | Create post
26 | {icon}
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/forms/form-select.component.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '@headlessui/react';
2 | import classNames from 'classnames';
3 | import React, { SelectHTMLAttributes, useState } from 'react';
4 |
5 | interface FormSelectProps extends SelectHTMLAttributes {
6 | className?: string;
7 | sizeClass?: string;
8 | }
9 |
10 | export const FormSelect: React.FC = ({
11 | className = '',
12 | sizeClass = 'h-11',
13 | children,
14 | ...rest
15 | }) => {
16 | return (
17 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/firebase/types.ts:
--------------------------------------------------------------------------------
1 | import { FieldValue } from 'firebase/firestore';
2 |
3 | export interface IFirestoreUserData {
4 | username: string;
5 | displayName: string;
6 | photoURL: string;
7 | createdAt: number;
8 | }
9 |
10 | export interface IFirestoreUsernameData {
11 | username: string;
12 | uid: string;
13 | createdAt: number;
14 | }
15 |
16 | export interface IFirestorePostData {
17 | // author specific data
18 | uid: string;
19 | username: string;
20 | // post specific data
21 | title: string;
22 | slug: string;
23 | content: string;
24 | createdAt: number;
25 | updatedAt: number;
26 | published: boolean;
27 | thumbnail: string;
28 | href: string;
29 | description: string;
30 | likeCount: number;
31 | }
32 |
33 | export interface IFirestorePostPayload
34 | extends Omit {
35 | createdAt: FieldValue;
36 | updatedAt: FieldValue;
37 | }
38 |
--------------------------------------------------------------------------------
/libs/ui/src/index.ts:
--------------------------------------------------------------------------------
1 | // file modules
2 | export * from './lib/toast.component';
3 | export * from './lib/loader.component';
4 | export * from './lib/avatar.component';
5 | export * from './lib/toast.component';
6 | export * from './lib/header.component';
7 | export * from './lib/page-heading.component';
8 | export * from './lib/app-layout.component';
9 | export * from './lib/input.component';
10 | export * from './lib/link.component';
11 | export * from './lib/footer.component';
12 | export * from './lib/meta-tags.component';
13 | export * from './lib/label.component';
14 | export * from './lib/auth-check.component';
15 | // folder modules
16 | export * from './lib/navbar';
17 | export * from './lib/buttons';
18 | export * from './lib/forms';
19 | export * from './lib/dark-mode-toggle';
20 | export * from './lib/image';
21 | export * from './lib/post';
22 | export * from './lib/sliders';
23 | export * from './lib/badge';
24 | export * from './lib/portal';
25 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/link.component.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import classNames from 'classnames';
3 | import NextLink, { LinkProps as NextLinkProps } from 'next/link';
4 | import { useRouter } from 'next/router';
5 | import { ReactNode } from 'react';
6 |
7 | interface LinkProps extends NextLinkProps {
8 | className?: string;
9 | colorClass?: string;
10 | activeClassName?: string;
11 | children?: ReactNode;
12 | }
13 |
14 | export const Link = ({
15 | className = 'font-medium',
16 | colorClass = 'text-primary-6000 hover:text-primary-800 dark:text-primary-500 dark:hover:text-primary-6000',
17 | activeClassName = '',
18 | children,
19 | ...rest
20 | }: LinkProps) => {
21 | const router = useRouter();
22 |
23 | return (
24 |
30 | {children}
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/dark-mode-toggle/dark-mode-switch.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 | import { FiMoon, FiSun } from 'react-icons/fi';
4 |
5 | type DarkModeSwitchProps = {
6 | className?: string;
7 | isDarkMode: boolean;
8 | onClick: () => void;
9 | };
10 |
11 | export const DarkModeSwitch: React.FC = ({
12 | className,
13 | isDarkMode,
14 | onClick,
15 | }) => {
16 | return (
17 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/meta-tags.component.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import React from 'react';
3 |
4 | type MetatagsProps = {
5 | title?: string;
6 | description?: string;
7 | image?: string;
8 | };
9 |
10 | export const Metatags: React.FC = ({
11 | title = 'Bloggo - a blog platform for the modern web',
12 | description = 'A blog platform to write, publish, and share stories.',
13 | image = 'blob:https://vercel.com/c05fcf8a-4a1a-4ede-9a32-af71977cfb7c',
14 | }) => {
15 | return (
16 |
17 | {title} || Bloggo
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import React from 'react';
3 |
4 | import { AppLayout, Button, Metatags } from '@bloggo/ui';
5 |
6 | const Page404: React.FC = () => {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 | 🪔
14 |
15 | 404
16 |
17 |
18 | Whoops, the page you requested doesn't exists.{' '}
19 |
20 |
23 |
24 |
25 |
26 | >
27 | );
28 | };
29 |
30 | export default Page404;
31 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/badge/badge-list.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Badge, BadgeColor } from './badge.component';
4 |
5 | // TODO: cleanup the mock data
6 | const categories = [
7 | {
8 | id: 15,
9 | name: 'Computers',
10 | href: '/archive/the-demo-archive-slug',
11 | thumbnail:
12 | 'https://images.unsplash.com/photo-1532529867795-3c83442c1e5c?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=80',
13 | count: 26,
14 | color: 'blue',
15 | },
16 | ].map((category) => ({ ...category, taxonomy: 'category' }));
17 |
18 | type BadgeListProps = {
19 | badgeClassName?: string;
20 | };
21 |
22 | export const BadgeList: React.FC = ({ badgeClassName }) => {
23 | return (
24 | <>
25 | {categories.map((category, index) => (
26 |
33 | ))}
34 | >
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [
3 | {
4 | "collectionGroup": "posts",
5 | "queryScope": "COLLECTION_GROUP",
6 | "fields": [
7 | {
8 | "fieldPath": "published",
9 | "order": "ASCENDING"
10 | },
11 | {
12 | "fieldPath": "createdAt",
13 | "order": "DESCENDING"
14 | }
15 | ]
16 | },
17 | {
18 | "collectionGroup": "posts",
19 | "queryScope": "COLLECTION_GROUP",
20 | "fields": [
21 | {
22 | "fieldPath": "published",
23 | "order": "ASCENDING"
24 | },
25 | {
26 | "fieldPath": "likeCount",
27 | "order": "DESCENDING"
28 | }
29 | ]
30 | },
31 | {
32 | "collectionGroup": "posts",
33 | "queryScope": "COLLECTION",
34 | "fields": [
35 | {
36 | "fieldPath": "published",
37 | "order": "ASCENDING"
38 | },
39 | {
40 | "fieldPath": "createdAt",
41 | "order": "DESCENDING"
42 | }
43 | ]
44 | }
45 | ],
46 | "fieldOverrides": []
47 | }
48 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/input.component.tsx:
--------------------------------------------------------------------------------
1 | import React, { InputHTMLAttributes, forwardRef } from 'react';
2 |
3 | export interface InputProps extends InputHTMLAttributes {
4 | sizeClass?: string;
5 | fontClass?: string;
6 | rounded?: string;
7 | }
8 |
9 | export const Input: React.ForwardRefExoticComponent<
10 | InputProps & React.RefAttributes
11 | > = forwardRef(
12 | (
13 | {
14 | className = '',
15 | sizeClass = 'h-11 px-4 py-3',
16 | fontClass = 'text-sm font-normal',
17 | rounded = 'rounded-full',
18 | children,
19 | type = 'text',
20 | ...args
21 | },
22 | ref,
23 | ) => {
24 | return (
25 |
31 | );
32 | },
33 | );
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2023 msanvarov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/social-media-links.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 | import { FiGithub } from 'react-icons/fi';
4 |
5 | import { ISocialMedia } from './buttons/share-post-button-container-component';
6 |
7 | type SocialsListProps = {
8 | className?: string;
9 | itemClass?: string;
10 | socials?: ISocialMedia[];
11 | };
12 |
13 | const appSocials: ISocialMedia[] = [
14 | { id: 'Github', name: 'Github', icon: , href: '#' },
15 | ];
16 |
17 | export const SocialMediaLinks: React.FC = ({
18 | className,
19 | itemClass = 'block',
20 | socials = appSocials,
21 | }) => {
22 | return (
23 |
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/apps/bloggo/styles/_theme-variables.scss:
--------------------------------------------------------------------------------
1 | :root {
2 | --c-primary-50: 239, 246, 255;
3 | --c-primary-100: 219, 234, 254;
4 | --c-primary-200: 191, 219, 254;
5 | --c-primary-300: 147, 197, 253;
6 | --c-primary-400: 96, 165, 250;
7 | --c-primary-500: 59, 130, 246;
8 | --c-primary-600: 37, 99, 235;
9 | --c-primary-700: 29, 78, 216;
10 | --c-primary-800: 30, 64, 175;
11 | --c-primary-900: 30, 58, 138;
12 | // SECONDARY COLOR
13 | --c-secondary-50: 240, 253, 250;
14 | --c-secondary-100: 204, 251, 241;
15 | --c-secondary-200: 153, 246, 228;
16 | --c-secondary-300: 153, 246, 228;
17 | --c-secondary-400: 45, 212, 191;
18 | --c-secondary-500: 20, 184, 166;
19 | --c-secondary-600: 13, 148, 136;
20 | --c-secondary-700: 15, 118, 110;
21 | --c-secondary-800: 17, 94, 89;
22 | --c-secondary-900: 19, 78, 74;
23 | // NEUTRAL COLOR
24 | --c-neutral-50: 248, 250, 252;
25 | --c-neutral-100: 241, 245, 249;
26 | --c-neutral-200: 226, 232, 240;
27 | --c-neutral-300: 203, 213, 225;
28 | --c-neutral-400: 148, 163, 184;
29 | --c-neutral-500: 100, 116, 139;
30 | --c-neutral-600: 71, 85, 105;
31 | --c-neutral-700: 51, 65, 85;
32 | --c-neutral-800: 30, 41, 59;
33 | --c-neutral-900: 15, 23, 42;
34 | }
35 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/dark-mode-toggle/dark-mode-toggle-container.component.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | import {
4 | AppState,
5 | toggleDarkMode,
6 | useAppDispatch,
7 | useAppSelector,
8 | } from '@bloggo/redux';
9 |
10 | import { DarkModeSwitch } from './dark-mode-switch.component';
11 |
12 | type DarkModeToggleContainerProps = {
13 | className?: string;
14 | };
15 |
16 | export const DarkModeToggleContainer: React.FC<
17 | DarkModeToggleContainerProps
18 | > = ({ className }) => {
19 | const dispatch = useAppDispatch();
20 | const darkMode = useAppSelector((state: AppState) => state.layout.darkMode);
21 | useEffect(() => {
22 | const root = document.querySelector('html');
23 | if (!root) return;
24 | if (darkMode) {
25 | !root.classList.contains('dark') && root.classList.add('dark');
26 | } else {
27 | root.classList.remove('dark');
28 | }
29 | }, [darkMode]);
30 |
31 | const toggleDarkModeOnClick = () => {
32 | dispatch(toggleDarkMode());
33 | };
34 | return (
35 |
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/apps/bloggo/public/twitter.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### macOS ###
2 | # General
3 | .DS_Store
4 | .AppleDouble
5 | .LSOverride
6 |
7 | # Thumbnails
8 | ._*
9 |
10 | # Files that might appear in the root of a volume
11 | .DocumentRevisions-V100
12 | .fseventsd
13 | .Spotlight-V100
14 | .TemporaryItems
15 | .Trashes
16 | .VolumeIcon.icns
17 | .com.apple.timemachine.donotpresent
18 |
19 | # Directories potentially created on remote AFP share
20 | .AppleDB
21 | .AppleDesktop
22 | Network Trash Folder
23 | Temporary Items
24 | .apdisk
25 |
26 | ### NextJS ###
27 | .next/
28 | dist/*
29 |
30 | ### Firebase ###
31 | **/.firebaserc
32 |
33 | ### Firebase Patch ###
34 | .runtimeconfig.json
35 | .firebase/
36 |
37 | ### React ###
38 | .DS_*
39 | *.log
40 | logs
41 | **/*.backup.*
42 | **/*.back.*
43 |
44 | node_modules
45 | bower_components
46 | psd
47 | thumb
48 | yarn.lock
49 | package-lock.json
50 |
51 | ### VisualStudioCode ###
52 | .vscode/*
53 | !.vscode/settings.json
54 | !.vscode/tasks.json
55 | !.vscode/launch.json
56 | !.vscode/extensions.json
57 | !.vscode/*.code-snippets
58 |
59 | # Local History for Visual Studio Code
60 | .history/
61 |
62 | # Built Visual Studio Code Extensions
63 | *.vsix
64 |
65 | ### VisualStudioCode Patch ###
66 | .history
67 | .ionide
68 |
69 | # Next.js
70 | .next
71 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/firebase/auth.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GoogleAuthProvider,
3 | createUserWithEmailAndPassword,
4 | signInWithEmailAndPassword,
5 | signInWithPopup,
6 | signOut,
7 | updateProfile,
8 | } from 'firebase/auth';
9 |
10 | import { auth } from './firebase';
11 |
12 | export const login = async (email: string, password: string) => {
13 | return await signInWithEmailAndPassword(auth, email, password);
14 | };
15 |
16 | export const loginWithGoogleProvider = async () => {
17 | const provider = new GoogleAuthProvider();
18 | const authResponse = await signInWithPopup(auth, provider);
19 | const credential = GoogleAuthProvider.credentialFromResult(authResponse);
20 | const token = credential.accessToken;
21 | console.log(token);
22 | return authResponse.user;
23 | };
24 |
25 | export const register = async (
26 | username: string,
27 | email: string,
28 | password: string,
29 | ) => {
30 | const { user } = await createUserWithEmailAndPassword(auth, email, password);
31 |
32 | updateProfile(user, {
33 | displayName: username,
34 | photoURL: `https://ui-avatars.com/api/?name=${username}`,
35 | });
36 |
37 | return user;
38 | };
39 |
40 | export const logout = async () => {
41 | return await signOut(auth);
42 | };
43 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/firebase/firebase.ts:
--------------------------------------------------------------------------------
1 | import { getApp, initializeApp } from 'firebase/app';
2 | import { getAuth } from 'firebase/auth';
3 | import { getFirestore } from 'firebase/firestore';
4 | import { getStorage } from 'firebase/storage';
5 |
6 | const firebaseConfig = {
7 | apiKey:
8 | process.env.FIREBASE_API_KEY ?? 'AIzaSyDNotinqdiNETddK4e_MA_rNsz1gkYKChw',
9 | authDomain:
10 | process.env.FIREBASE_AUTH_DOMAIN ?? 'bloggo-9fc7d.firebaseapp.com',
11 | projectId: process.env.FIREBASE_PROJECT_ID ?? 'bloggo-9fc7d',
12 | storageBucket:
13 | process.env.FIREBASE_STORAGE_BUCKET ?? 'bloggo-9fc7d.appspot.com',
14 | messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID ?? '424368452854',
15 | appId:
16 | process.env.FIREBASE_APP_ID ?? '1:424368452854:web:361dd4c39219757306e8d7',
17 | measurementId: process.env.FIREBASE_MEASUREMENT_ID ?? 'G-D2F125TYVK',
18 | };
19 |
20 | export const initializeFirebase = () => {
21 | try {
22 | return getApp();
23 | } catch (e) {
24 | return initializeApp(firebaseConfig);
25 | }
26 | };
27 |
28 | const app = initializeFirebase();
29 | export const auth = getAuth(app);
30 | export const db = getFirestore(app);
31 | export const storage = getStorage(app);
32 | export const STATE_CHANGED = 'state_changed';
33 |
--------------------------------------------------------------------------------
/apps/bloggo-e2e/src/support/commands.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 |
11 | // eslint-disable-next-line @typescript-eslint/no-namespace
12 | declare namespace Cypress {
13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
14 | interface Chainable {
15 | login(email: string, password: string): void;
16 | }
17 | }
18 | //
19 | // -- This is a parent command --
20 | Cypress.Commands.add('login', (email, password) => {
21 | console.log('Custom command example: Login', email, password);
22 | });
23 | //
24 | // -- This is a child command --
25 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
26 | //
27 | //
28 | // -- This is a dual command --
29 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
30 | //
31 | //
32 | // -- This will overwrite an existing command --
33 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
34 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/image/image-inview-port.util.ts:
--------------------------------------------------------------------------------
1 | export interface InviewPortType {
2 | distanceFromEnd: number;
3 | callback: () => boolean;
4 | target: HTMLElement;
5 | }
6 |
7 | const checkInViewIntersectionObserver = ({
8 | target,
9 | distanceFromEnd,
10 | callback,
11 | }: InviewPortType) => {
12 | const _funCallback: IntersectionObserverCallback = (
13 | entries: IntersectionObserverEntry[],
14 | observer: IntersectionObserver,
15 | ) => {
16 | entries.map((entry: IntersectionObserverEntry) => {
17 | if (entry.isIntersecting) {
18 | // NEED CALLBACK WILL RETURN BOOLEAN ---- IF TRUE WE WILL UNOBSERVER AND FALSE IS NO
19 | const unobserve = callback();
20 | unobserve && observer.unobserve(entry.target);
21 | }
22 | return true;
23 | });
24 | };
25 |
26 | // _checkBrowserSupport-----
27 | if (typeof window.IntersectionObserver === 'undefined') {
28 | console.error(
29 | 'window.IntersectionObserver === undefined! => Your Browser is Notsupport',
30 | );
31 | return;
32 | }
33 | const options = {
34 | root: null,
35 | rootMargin: `${distanceFromEnd}px 0px`,
36 | threshold: 0,
37 | };
38 | const observer = new IntersectionObserver(_funCallback, options);
39 | target && observer.observe(target);
40 | };
41 |
42 | export default checkInViewIntersectionObserver;
43 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/store.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers, configureStore } from '@reduxjs/toolkit';
2 | import { setupListeners } from '@reduxjs/toolkit/query/react';
3 | import {
4 | FLUSH,
5 | PAUSE,
6 | PERSIST,
7 | PURGE,
8 | REGISTER,
9 | REHYDRATE,
10 | persistReducer,
11 | persistStore,
12 | } from 'redux-persist';
13 | import storage from 'redux-persist/lib/storage';
14 |
15 | import { layoutReducer } from './layout';
16 | import { userReducer } from './user';
17 |
18 | const persistConfig = {
19 | key: 'root',
20 | version: 1,
21 | storage,
22 | blacklist: ['user'],
23 | };
24 |
25 | const reducer = combineReducers({
26 | user: userReducer,
27 | layout: layoutReducer,
28 | });
29 | const persistedReducer = persistReducer(persistConfig, reducer);
30 |
31 | export const store = configureStore({
32 | reducer: persistedReducer,
33 | middleware: (getDefaultMiddleware) =>
34 | getDefaultMiddleware({
35 | serializableCheck: {
36 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
37 | },
38 | }),
39 | // .concat(routerMiddleware(history))
40 | devTools: true,
41 | });
42 |
43 | setupListeners(store.dispatch);
44 |
45 | export const persistor = persistStore(store);
46 | // top-level state
47 | export type AppDispatch = typeof store.dispatch;
48 | export type AppState = ReturnType;
49 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '@glidejs/glide/dist/css/glide.core.min.css';
2 | import { Analytics } from '@vercel/analytics/react';
3 | import 'moment-timezone';
4 | import { AppProps } from 'next/app';
5 | import Head from 'next/head';
6 | import { useRouter } from 'next/router';
7 | import { useEffect } from 'react';
8 | import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css';
9 | import { Provider } from 'react-redux';
10 | import { PersistGate } from 'redux-persist/integration/react';
11 |
12 | import { persistor, store } from '@bloggo/redux';
13 | import { Footer, Header, Loader } from '@bloggo/ui';
14 |
15 | import '../styles/styles.scss';
16 |
17 | const CustomApp: React.FC = ({ Component, pageProps }) => {
18 | const router = useRouter();
19 |
20 | useEffect(() => {
21 | // On router change
22 | window.scrollTo(0, 0);
23 | }, [router.asPath]);
24 | return (
25 |
26 | } {...{ persistor }}>
27 |
28 | Welcome to bloggo!
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default CustomApp;
42 |
--------------------------------------------------------------------------------
/apps/bloggo/public/google.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/portal/portal-navbar.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Link } from '../link.component';
4 |
5 | const pages = [
6 | {
7 | path: '/portal/create-post',
8 | emoji: '✍',
9 | pageName: 'Create Post',
10 | },
11 | {
12 | path: '/portal/posts',
13 | emoji: '📕',
14 | pageName: 'Posts',
15 | },
16 | {
17 | path: '/portal/liked-posts',
18 | emoji: '💖',
19 | pageName: 'Liked Posts',
20 | },
21 | {
22 | path: '/portal/edit-profile',
23 | emoji: '🛠',
24 | pageName: 'Edit Profile',
25 | },
26 | ];
27 |
28 | export const PortalNavbar: React.FC = () => {
29 | return (
30 |
31 |
32 | {pages.map(({ path, pageName, emoji }, index) => {
33 | return (
34 | -
35 |
41 | {emoji}
42 | {pageName}
43 |
44 |
45 | );
46 | })}
47 |
48 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/section-heading.component.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | import React, { HTMLAttributes, ReactNode } from 'react';
3 |
4 | import NextPrevButtons from './buttons/next-prev-buttons.component';
5 |
6 | export interface SectionHeadingProps
7 | extends HTMLAttributes {
8 | fontClass?: string;
9 | desc?: ReactNode;
10 | hasNextPrev?: boolean;
11 | isCenter?: boolean;
12 | }
13 |
14 | export const SectionHeading: React.FC = ({
15 | children,
16 | desc = 'Placeholder description.',
17 | className = 'mb-12 md:mb-16 text-neutral-900 dark:text-neutral-50',
18 | isCenter = false,
19 | hasNextPrev = false,
20 | ...rest
21 | }) => {
22 | return (
23 |
26 |
31 |
32 | {children || `Section Heading`}
33 |
34 | {desc && (
35 |
36 | {desc}
37 |
38 | )}
39 |
40 | {hasNextPrev && !isCenter && (
41 |
42 |
43 |
44 | )}
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/buttons/share-post-button-container-component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 | import { FiFacebook, FiInstagram, FiLinkedin, FiTwitter } from 'react-icons/fi';
4 |
5 | type SharePostButtonContainerProps = {
6 | className?: string;
7 | itemClass?: string;
8 | };
9 |
10 | export interface ISocialMedia {
11 | id: string;
12 | name: string;
13 | icon: JSX.Element;
14 | href: string;
15 | }
16 |
17 | // these social media links are for the blog page
18 | const socials: ISocialMedia[] = [
19 | { id: 'Facebook', name: 'Facebook', icon: , href: '#' },
20 | { id: 'Twitter', name: 'Twitter', icon: , href: '#' },
21 | { id: 'Linkedin', name: 'Linkedin', icon: , href: '#' },
22 | { id: 'Instagram', name: 'Instagram', icon: , href: '#' },
23 | ];
24 |
25 | export const SharePostButtonContainer: React.FC<
26 | SharePostButtonContainerProps
27 | > = ({
28 | className = 'grid gap-[6px]',
29 | itemClass = 'w-7 h-7 text-base hover:bg-neutral-100',
30 | }) => {
31 | return (
32 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/forms/form-feedback.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 | import { FiXCircle } from 'react-icons/fi';
4 |
5 | type FormFeedbackProps = {
6 | type: 'warning' | 'info' | 'success' | 'error';
7 | message: string;
8 | };
9 | export const FormFeedback: React.FC = ({
10 | type,
11 | message,
12 | }) => {
13 | return (
14 |
22 |
23 |
24 |
33 |
34 |
35 |
43 | {message}
44 |
45 |
46 |
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/nx.json:
--------------------------------------------------------------------------------
1 | {
2 | "npmScope": "bloggo",
3 | "affected": {
4 | "defaultBase": "main"
5 | },
6 | "tasksRunnerOptions": {
7 | "default": {
8 | "runner": "nx-cloud",
9 | "options": {
10 | "cacheableOperations": ["build", "lint", "test", "e2e"],
11 | "accessToken": "ZTNiM2IzYzctNzhlMS00MTA4LTkwYWMtMGRiYjQ4MGM1NTA3fHJlYWQtd3JpdGU="
12 | }
13 | }
14 | },
15 | "generators": {
16 | "@nx/react": {
17 | "application": {
18 | "babel": true
19 | }
20 | },
21 | "@nx/next": {
22 | "application": {
23 | "style": "scss",
24 | "linter": "eslint"
25 | }
26 | }
27 | },
28 | "defaultProject": "bloggo",
29 | "$schema": "./node_modules/nx/schemas/nx-schema.json",
30 | "targetDefaults": {
31 | "build": {
32 | "dependsOn": ["^build"],
33 | "inputs": ["production", "^production"]
34 | },
35 | "lint": {
36 | "inputs": ["default", "{workspaceRoot}/.eslintrc.json"]
37 | },
38 | "e2e": {
39 | "inputs": ["default", "^production"]
40 | },
41 | "test": {
42 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"]
43 | }
44 | },
45 | "namedInputs": {
46 | "default": ["{projectRoot}/**/*", "sharedGlobals"],
47 | "sharedGlobals": ["{workspaceRoot}/babel.config.json"],
48 | "production": [
49 | "default",
50 | "!{projectRoot}/.eslintrc.json",
51 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
52 | "!{projectRoot}/tsconfig.spec.json",
53 | "!{projectRoot}/jest.config.[jt]s",
54 | "!{projectRoot}/src/test-setup.[jt]s"
55 | ]
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/post-preview-card-author.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Moment from 'react-moment';
3 |
4 | import { IFirestoreUserData } from '@bloggo/redux';
5 |
6 | import { Avatar } from '../avatar.component';
7 | import { Link } from '../link.component';
8 |
9 | type PostPreviewCardAuthorProps = {
10 | className?: string;
11 | author: IFirestoreUserData;
12 | date: number;
13 | hiddenAvatar?: boolean;
14 | size?: 'large' | 'normal';
15 | };
16 |
17 | export const PostPreviewCardAuthor: React.FC = ({
18 | className = 'leading-none',
19 | author,
20 | date,
21 | hiddenAvatar = false,
22 | size = 'normal',
23 | }) => {
24 | return (
25 |
30 |
34 | {!hiddenAvatar && (
35 |
43 | )}
44 |
45 | {author.displayName}
46 |
47 |
48 |
49 |
50 | ·
51 |
52 |
53 | {date}
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/buttons/bookmark-post-button.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React, { useState } from 'react';
3 |
4 | type BookmarkPostButtonProps = {
5 | containerClassName?: string;
6 | iconClass?: string;
7 | initBookmarked: boolean;
8 | postId: string;
9 | };
10 | // TODO: redux state
11 | export const BookmarkPostButton: React.FC = ({
12 | containerClassName = 'h-8 w-8 bg-neutral-50 hover:bg-neutral-100 dark:bg-neutral-800 dark:hover:bg-neutral-700',
13 | postId,
14 | initBookmarked,
15 | }) => {
16 | const [isBookmarkPostButtonClicked, setIsBookmarkPostButtonClicked] =
17 | useState(false);
18 | const isBookmarked = () => {
19 | return isBookmarkPostButtonClicked || initBookmarked;
20 | };
21 | const handleOnBookmarkButtonClick = () => {
22 | if (isBookmarked()) {
23 | setIsBookmarkPostButtonClicked(false);
24 | } else {
25 | setIsBookmarkPostButtonClicked(true);
26 | }
27 | };
28 |
29 | return (
30 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/apps/bloggo/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bloggo",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "apps/bloggo",
5 | "projectType": "application",
6 | "targets": {
7 | "build": {
8 | "executor": "@nx/next:build",
9 | "outputs": [
10 | "{options.outputPath}"
11 | ],
12 | "defaultConfiguration": "production",
13 | "options": {
14 | "outputPath": "dist/apps/bloggo"
15 | },
16 | "configurations": {
17 | "production": {},
18 | "development": {
19 | "outputPath": "apps/bloggo"
20 | }
21 | }
22 | },
23 | "serve": {
24 | "executor": "@nx/next:server",
25 | "options": {
26 | "buildTarget": "bloggo:build",
27 | "dev": true
28 | },
29 | "configurations": {
30 | "production": {
31 | "buildTarget": "bloggo:build:production",
32 | "dev": false
33 | },
34 | "development": {
35 | "buildTarget": "bloggo:build:development",
36 | "dev": true
37 | }
38 | },
39 | "defaultConfiguration": "development"
40 | },
41 | "export": {
42 | "executor": "@nx/next:export",
43 | "options": {
44 | "buildTarget": "bloggo:build:production"
45 | }
46 | },
47 | "test": {
48 | "executor": "@nx/jest:jest",
49 | "outputs": [
50 | "{workspaceRoot}/coverage/apps/bloggo"
51 | ],
52 | "options": {
53 | "jestConfig": "apps/bloggo/jest.config.ts",
54 | "passWithNoTests": true
55 | }
56 | },
57 | "lint": {
58 | "executor": "@nx/linter:eslint",
59 | "outputs": [
60 | "{options.outputFile}"
61 | ],
62 | "options": {
63 | "lintFilePatterns": [
64 | "apps/bloggo/**/*.{ts,tsx,js,jsx}"
65 | ]
66 | }
67 | }
68 | },
69 | "tags": []
70 | }
71 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/avatar.component.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import React from 'react';
3 |
4 | type AvatarProps = {
5 | containerClassName?: string;
6 | sizeClass?: string;
7 | radius?: string;
8 | src?: string;
9 | userName?: string;
10 | };
11 |
12 | const avatarColors = [
13 | '#ffdd00',
14 | '#fbb034',
15 | '#ff4c4c',
16 | '#c1d82f',
17 | '#f48924',
18 | '#7ac143',
19 | '#30c39e',
20 | '#06BCAE',
21 | '#0695BC',
22 | '#037ef3',
23 | '#146eb4',
24 | '#8e43e7',
25 | '#ea1d5d',
26 | '#fc636b',
27 | '#ff6319',
28 | '#e01f3d',
29 | '#a0ac48',
30 | '#00d1b2',
31 | '#472f92',
32 | '#388ed1',
33 | '#a6192e',
34 | '#4a8594',
35 | '#7B9FAB',
36 | '#1393BD',
37 | '#5E13BD',
38 | '#E208A7',
39 | ];
40 |
41 | export const Avatar: React.FC = ({
42 | containerClassName = 'ring-1 ring-white dark:ring-neutral-900',
43 | sizeClass = 'h-6 w-6 text-sm',
44 | radius = 'rounded-md',
45 | src,
46 | userName,
47 | }) => {
48 | const url = src || '';
49 | const name = userName || 'Anonymous';
50 | const _setBgColor = (name: string) => {
51 | const backgroundIndex = Math.floor(
52 | name.charCodeAt(0) % avatarColors.length,
53 | );
54 | return avatarColors[backgroundIndex];
55 | };
56 |
57 | return (
58 |
62 | {url && (
63 |
69 | )}
70 | {name[0]}
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/portal/liked-posts.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { AppLayout, AuthCheck, Metatags, PortalNavbar } from '@bloggo/ui';
4 |
5 | const LikedPostsPage: React.FC = () => {
6 | return (
7 | <>
8 |
9 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | |
27 | Post
28 | |
29 |
30 |
31 | {/* {uid && } */}
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | >
42 | );
43 | };
44 |
45 | export default LikedPostsPage;
46 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/search-dropdown.component.tsx:
--------------------------------------------------------------------------------
1 | import { Popover, Transition } from '@headlessui/react';
2 | import React, { Fragment, useRef } from 'react';
3 | import { FiSearch } from 'react-icons/fi';
4 |
5 | import { Input } from './input.component';
6 |
7 | export const SearchDropdown: React.FC = () => {
8 | const inputElRef = useRef(null);
9 |
10 | return (
11 |
12 | {({ open }) => {
13 | if (open) {
14 | setTimeout(() => {
15 | inputElRef.current?.focus();
16 | }, 100);
17 | }
18 |
19 | return (
20 | <>
21 |
22 |
23 |
24 |
25 |
35 |
39 |
47 |
48 |
49 | >
50 | );
51 | }}
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/buttons/next-prev-buttons.component.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | import React from 'react';
3 | import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
4 |
5 | export interface NextPrevButtonsProps {
6 | containerClassName?: string;
7 | currentPage?: number;
8 | totalPage?: number;
9 | btnClassName?: string;
10 | onClickNext?: () => void;
11 | onClickPrev?: () => void;
12 | onlyNext?: boolean;
13 | onlyPrev?: boolean;
14 | }
15 | const NextPrevButtons: React.FC = ({
16 | containerClassName = '',
17 | onClickNext = () => {},
18 | onClickPrev = () => {},
19 | btnClassName = 'w-10 h-10',
20 | onlyNext = false,
21 | onlyPrev = false,
22 | }) => {
23 | return (
24 |
29 | {!onlyNext && (
30 |
40 | )}
41 | {!onlyPrev && (
42 |
50 | )}
51 |
52 | );
53 | };
54 |
55 | export default NextPrevButtons;
56 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/footer.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Logo } from './logo.component';
4 | import { SocialMediaLinks } from './social-media-links.component';
5 |
6 | const footerNavigationOptions = [
7 | {
8 | id: '1',
9 | title: 'Explore',
10 | menus: [
11 | { href: '/login', label: 'Login' },
12 | { href: '/register', label: 'Register' },
13 | { href: '/forgot-password', label: 'Forgot Password' },
14 | ],
15 | },
16 | ];
17 |
18 | export const Footer: React.FC = () => {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {footerNavigationOptions.map((menu, index) => (
31 |
32 |
33 | {menu.title}
34 |
35 |
48 |
49 | ))}
50 |
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/post-preview-card-action-buttons.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { AppState, useAppSelector } from '@bloggo/redux';
4 |
5 | import { BookmarkPostButton, LikePostButton } from '../buttons';
6 | import { CommentOnPostButton } from '../buttons/comment-on-post-button.component';
7 |
8 | type PostPreviewCardActionButtonsProps = {
9 | className?: string;
10 | itemClass?: string;
11 | likes: number;
12 | postId: string;
13 | href: string;
14 | isBookmarked: boolean;
15 | commentCount: number;
16 | hiddenCommentOnMobile?: boolean;
17 | onClickLike?: (id: string) => void;
18 | classBgIcon?: string;
19 | };
20 | export const PostPreviewCardActionButtons: React.FC<
21 | PostPreviewCardActionButtonsProps
22 | > = ({
23 | className = '',
24 | itemClass = 'px-3 h-8 text-xs',
25 | hiddenCommentOnMobile = true,
26 | isBookmarked,
27 | likes,
28 | postId,
29 | href,
30 | commentCount,
31 | classBgIcon,
32 | // eslint-disable-next-line @typescript-eslint/no-empty-function
33 | onClickLike = () => {},
34 | }) => {
35 | const { user } = useAppSelector((state: AppState) => state.user);
36 | return (
37 | <>
38 |
39 | {user?.uid && (
40 |
44 | )}
45 |
46 |
53 |
54 |
57 | 6 min read
58 | {/* TODO: fix postId to have the slug */}
59 |
64 |
65 | >
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/navbar/navigation.component.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import { nanoid } from '@reduxjs/toolkit';
3 | import { entries, groupBy, map } from 'lodash';
4 | import React, { Fragment } from 'react';
5 |
6 | import { AppState, useAppSelector } from '@bloggo/redux';
7 |
8 | import { INavEntry, NavigationEntry } from './navigation-entry.component';
9 |
10 | const generateUUID = () => `nc_${nanoid()}`;
11 |
12 | const dashboardNavigationMenus: INavEntry[] = [
13 | {
14 | id: generateUUID(),
15 | href: '/portal/posts',
16 | name: 'Posts',
17 | },
18 | {
19 | id: generateUUID(),
20 | href: '/portal/liked-posts',
21 | name: 'Liked Posts',
22 | },
23 | {
24 | id: generateUUID(),
25 | href: '/portal/edit-profile',
26 | name: 'Profile',
27 | },
28 | ];
29 |
30 | export const navigationOptions: INavEntry[] = [
31 | // hidden menu options until authentication is completed
32 | {
33 | id: generateUUID(),
34 | href: '/dashboard',
35 | name: 'Dashboard',
36 | type: 'dropdown',
37 | children: dashboardNavigationMenus,
38 | restriction: 'private',
39 | },
40 | {
41 | id: generateUUID(),
42 | href: 'https://github.com/msanvarov/bloggo',
43 | name: 'Github',
44 | restriction: 'public',
45 | },
46 | ];
47 |
48 | export const Navigation: React.FC = () => {
49 | const { user } = useAppSelector((state: AppState) => state.user);
50 | return (
51 |
52 | {map(
53 | entries(groupBy(navigationOptions, 'restriction')),
54 | ([restriction, navigationEntries], i) => {
55 | if (restriction === 'private' && !user) {
56 | return null;
57 | }
58 | return (
59 |
60 | {navigationEntries.map((navigationEntry) => (
61 |
65 | ))}
66 |
67 | );
68 | },
69 | )}
70 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/post-edit.component.tsx:
--------------------------------------------------------------------------------
1 | import { doc } from 'firebase/firestore';
2 | import React from 'react';
3 | import { useDocumentDataOnce } from 'react-firebase-hooks/firestore';
4 | import { Data } from 'react-firebase-hooks/firestore/dist/firestore/types';
5 |
6 | import { IFirestorePostData, db } from '@bloggo/redux';
7 |
8 | import { BadgeList } from '../badge';
9 | import { ImageContainer } from '../image';
10 | import { PostForm } from './post-form.component';
11 |
12 | type PostEditProps = {
13 | className?: string;
14 | uid: string;
15 | slug: string | string[];
16 | };
17 |
18 | export const PostEdit: React.FC = ({ uid, slug }) => {
19 | // TODO: cleanup
20 | const postRef = doc(db, `users/${uid}/posts/${slug}`);
21 | const [post] = useDocumentDataOnce(postRef);
22 | return post ? (
23 | <>
24 |
25 |
26 |
27 |
28 |
29 | {post['title']}
30 |
31 | {post['description'] && (
32 |
33 | {post['description']}
34 |
35 | )}
36 |
37 |
38 |
39 |
40 |
49 |
55 | >
56 | ) : (
57 | Post couldn't be found...
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/sliders/newest-authors-slider.component.tsx:
--------------------------------------------------------------------------------
1 | import Glide from '@glidejs/glide';
2 | import { nanoid } from '@reduxjs/toolkit';
3 | import React, { useEffect } from 'react';
4 |
5 | import { IFirestoreUsernameData } from '@bloggo/redux';
6 |
7 | import NextPrevButtons from '../buttons/next-prev-buttons.component';
8 | import { AuthorCard } from '../cards/author-card.component';
9 | import { SectionHeading } from '../section-heading.component';
10 |
11 | type NewestAuthorsSliderProps = {
12 | className?: string;
13 | heading: string;
14 | subHeading: string;
15 | authors: IFirestoreUsernameData[];
16 | };
17 |
18 | export const NewestAuthorsSlider: React.FC = ({
19 | heading,
20 | subHeading,
21 | className = '',
22 | authors,
23 | }) => {
24 | const GLIDE_SELECTOR_CLASS = 'glide_' + nanoid();
25 | useEffect(() => {
26 | setTimeout(() => {
27 | new Glide(`.${GLIDE_SELECTOR_CLASS}`, {
28 | perView: 5,
29 | gap: 32,
30 | bound: true,
31 | breakpoints: {
32 | 1280: {
33 | perView: 4,
34 | },
35 | 1023: {
36 | gap: 24,
37 | perView: 3,
38 | },
39 | 767: {
40 | gap: 20,
41 | perView: 2,
42 | },
43 | 639: {
44 | gap: 20,
45 | perView: 2,
46 | },
47 | 500: {
48 | gap: 20,
49 | perView: 1,
50 | },
51 | },
52 | }).mount();
53 | }, 100);
54 | }, []);
55 | return (
56 |
57 |
58 |
59 | {heading}
60 |
61 |
62 |
63 | {authors.map((item, index) => (
64 | -
65 |
66 |
67 | ))}
68 |
69 |
70 |
74 |
75 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/buttons/comment-on-post-button.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Link } from '../link.component';
4 |
5 | type CommentOnPostButton = {
6 | className?: string;
7 | href: string;
8 | commentCount?: number;
9 | };
10 | export const CommentOnPostButton: React.FC = ({
11 | className = 'flex px-3 h-8 text-xs',
12 | href,
13 | commentCount,
14 | }) => {
15 | return (
16 |
21 |
48 |
49 |
50 | {commentCount}
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/post-entry-metadata.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Moment from 'react-moment';
3 |
4 | import { Avatar } from '../avatar.component';
5 | import { Link } from '../link.component';
6 |
7 | type PostEntryMetadataProps = {
8 | className?: string;
9 | meta: {
10 | author: {
11 | username: string;
12 | };
13 | date:
14 | | number
15 | | {
16 | seconds: number;
17 | nanoseconds: number;
18 | };
19 | readingTime: string;
20 | };
21 | size?: 'large' | 'normal';
22 | avatarRounded?: string;
23 | };
24 |
25 | export const PostEntryMetadata: React.FC = ({
26 | className = 'leading-none',
27 | meta: { date, author, readingTime },
28 | size = 'normal',
29 | avatarRounded,
30 | }) => {
31 | return (
32 |
38 |
43 |
53 |
54 |
55 |
56 |
61 | {author.username}
62 |
63 |
64 |
65 |
66 |
67 | {typeof date === 'object' ? date.seconds : date}
68 |
69 |
70 | ·
71 |
72 | {readingTime} min read
73 |
74 |
75 |
76 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/badge/badge.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 |
4 | import { Link } from '../link.component';
5 |
6 | export type BadgeColor =
7 | | 'pink'
8 | | 'green'
9 | | 'yellow'
10 | | 'red'
11 | | 'indigo'
12 | | 'blue'
13 | | 'purple'
14 | | 'gray';
15 |
16 | type BadgeProps = {
17 | className?: string;
18 | name: React.ReactNode;
19 | color?: BadgeColor;
20 | href?: string;
21 | };
22 |
23 | export const Badge: React.FC = ({
24 | className = '',
25 | name,
26 | color = 'blue',
27 | href,
28 | }) => {
29 | const classes = classNames(
30 | 'relative inline-flex px-2.5 py-1 rounded-full font-medium text-xs',
31 | className,
32 | );
33 |
34 | const getColorClass = (hasHover = true) => {
35 | switch (color) {
36 | case 'pink':
37 | return `text-pink-800 bg-pink-100 ${
38 | hasHover ? 'hover:bg-pink-200' : ''
39 | }`;
40 | case 'red':
41 | return `text-red-800 bg-red-100 ${hasHover ? 'hover:bg-red-200' : ''}`;
42 | case 'gray':
43 | return `text-gray-800 bg-gray-100 ${
44 | hasHover ? 'hover:bg-gray-200' : ''
45 | }`;
46 | case 'green':
47 | return `text-green-800 bg-green-100 ${
48 | hasHover ? 'hover:bg-green-200' : ''
49 | }`;
50 | case 'purple':
51 | return `text-white-800 bg-purple-100 ${
52 | hasHover ? 'hover:bg-purple-200' : ''
53 | }`;
54 | case 'indigo':
55 | return `text-indigo-800 bg-indigo-100 ${
56 | hasHover ? 'hover:bg-indigo-200' : ''
57 | }`;
58 | case 'yellow':
59 | return `text-yellow-800 bg-yellow-100 ${
60 | hasHover ? 'hover:bg-yellow-200' : ''
61 | }`;
62 | case 'blue':
63 | return `text-blue-800 bg-blue-100 ${
64 | hasHover ? 'hover:bg-blue-200' : ''
65 | }`;
66 | default:
67 | return `text-pink-800 bg-pink-100 ${
68 | hasHover ? 'hover:bg-pink-200' : ''
69 | }`;
70 | }
71 | };
72 |
73 | return href ? (
74 |
82 | {name}
83 |
84 | ) : (
85 | {name}
86 | );
87 | };
88 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/portal/posts.tsx:
--------------------------------------------------------------------------------
1 | import { QueryDocumentSnapshot } from 'firebase/firestore';
2 | import React from 'react';
3 |
4 | import { AppState, useAppSelector } from '@bloggo/redux';
5 | import {
6 | AppLayout,
7 | AuthCheck,
8 | Metatags,
9 | PortalNavbar,
10 | UserPostsList,
11 | } from '@bloggo/ui';
12 |
13 | const PostsPage: React.FC = () => {
14 | const {
15 | user: { uid },
16 | } = useAppSelector((state: AppState) => state.user);
17 |
18 | return (
19 | <>
20 |
21 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | |
39 | Post
40 | |
41 |
42 | Status
43 | |
44 |
45 |
46 | Edit
47 | |
48 |
49 |
50 | {uid && }
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | >
61 | );
62 | };
63 |
64 | export default PostsPage;
65 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/menu-bar.component.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, Transition } from '@headlessui/react';
2 | import { useRouter } from 'next/router';
3 | import React, { Fragment, useEffect, useState } from 'react';
4 |
5 | import { MobileNavbar } from './navbar/mobile-navbar.component';
6 |
7 | export const MenuBar: React.FC = () => {
8 | const router = useRouter();
9 | const [showMenuBar, setShowMenuBar] = useState(false);
10 | // Hide menu bar on route change
11 | useEffect(() => {
12 | setShowMenuBar(false);
13 | }, [router.asPath]);
14 |
15 | const handleOpenMenu = () => setShowMenuBar(true);
16 | const handleCloseMenu = () => setShowMenuBar(false);
17 |
18 | return (
19 | <>
20 |
37 |
38 |
39 |
72 |
73 | >
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/post-preview-card.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import React from 'react';
3 |
4 | import { IFirestorePostData, IFirestoreUserData } from '@bloggo/redux';
5 |
6 | import { BadgeList } from '../badge';
7 | import { Link } from '../link.component';
8 | import { PostPreviewCardActionButtons } from './post-preview-card-action-buttons.component';
9 | import { PostPreviewCardAuthor } from './post-preview-card-author.component';
10 | import { PostThumbnail } from './post-thumbnail.component';
11 |
12 | interface PostPreviewCardProps {
13 | className?: string;
14 | author: IFirestoreUserData;
15 | post: IFirestorePostData;
16 | ratio?: string;
17 | isAuthorHidden?: boolean;
18 | }
19 |
20 | export const PostPreviewCard: React.FC = ({
21 | className = 'h-full',
22 | post,
23 | author,
24 | isAuthorHidden = false,
25 | ratio = 'aspect-w-4 aspect-h-3',
26 | }) => {
27 | return (
28 |
34 |
48 |
53 |
54 |
55 | {/* badges */}
56 |
57 |
58 |
59 |
60 |
61 | {!isAuthorHidden ? (
62 |
63 | ) : (
64 |
{post.createdAt}
65 | )}
66 |
67 |
68 | {post.title}
69 |
70 |
71 |
83 |
84 |
85 | );
86 | };
87 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/navbar/navbar.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { useRouter } from 'next/router';
3 | import { FiPenTool, FiSettings } from 'react-icons/fi';
4 |
5 | import { AppState, logout, useAppSelector } from '@bloggo/redux';
6 |
7 | import { Button } from '../buttons';
8 | import { DarkModeToggleContainer } from '../dark-mode-toggle';
9 | import { Logo } from '../logo.component';
10 | import { MenuBar } from '../menu-bar.component';
11 | import { SearchDropdown } from '../search-dropdown.component';
12 | import { NavbarActionButton } from './navbar-action-button.component';
13 | import './navbar.module.scss';
14 | import { Navigation } from './navigation.component';
15 |
16 | /* eslint-disable-next-line */
17 | export interface NavbarProps {
18 | isTopOfPage: boolean;
19 | }
20 |
21 | export const Navbar: React.FC = ({ isTopOfPage }) => {
22 | const router = useRouter();
23 | const { user } = useAppSelector((state: AppState) => state.user);
24 | return (
25 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {user ? (
41 | <>
42 |
}
45 | />
46 |
}
49 | />
50 |
51 |
60 |
61 | >
62 | ) : (
63 |
64 |
67 |
68 | )}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/portal/edit-profile.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { AppState, useAppSelector } from '@bloggo/redux';
4 | import {
5 | AppLayout,
6 | AuthCheck,
7 | Button,
8 | Input,
9 | Label,
10 | Metatags,
11 | PortalNavbar,
12 | } from '@bloggo/ui';
13 |
14 | const EditProfilePage: React.FC = () => {
15 | const { username } = useAppSelector((state: AppState) => state.user);
16 | return (
17 | <>
18 |
19 |
24 |
25 |
26 | {/* NAVBAR */}
27 |
28 |
29 |
30 |
76 |
77 |
78 |
79 | >
80 | );
81 | };
82 |
83 | export default EditProfilePage;
84 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/app-layout.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { getAuth } from 'firebase/auth';
3 | import { doc, getFirestore, onSnapshot } from 'firebase/firestore';
4 | import { ReactNode, useEffect } from 'react';
5 | import { useAuthState } from 'react-firebase-hooks/auth';
6 |
7 | import { setUser, setUsername, useAppDispatch } from '@bloggo/redux';
8 |
9 | import { PageHeading } from './page-heading.component';
10 |
11 | const db = getFirestore();
12 | const auth = getAuth();
13 |
14 | type AppLayoutProps = {
15 | className?: string;
16 | heading?: string;
17 | headingEmoji?: string;
18 | subHeading?: string;
19 | // basic layout
20 | basicLayout?: boolean;
21 | children: ReactNode;
22 | };
23 |
24 | export const AppLayout = ({
25 | className = '',
26 | heading,
27 | subHeading,
28 | headingEmoji,
29 | children,
30 | basicLayout = false,
31 | }: AppLayoutProps) => {
32 | const dispath = useAppDispatch();
33 | const [user] = useAuthState(auth);
34 |
35 | useEffect(() => {
36 | // turn off realtime subscription
37 | let unsubscribe;
38 |
39 | if (user) {
40 | dispath(
41 | setUser({
42 | uid: user.uid,
43 | email: user.email,
44 | emailVerified: user.emailVerified,
45 | displayName: user.displayName,
46 | photoURL: user.photoURL,
47 | createdAt: user.metadata.creationTime,
48 | lastLoginAt: user.metadata.lastSignInTime,
49 | }),
50 | );
51 | unsubscribe = onSnapshot(doc(db, 'users', user.uid), (doc) => {
52 | const data = doc.data();
53 | if (data) {
54 | dispath(setUsername(data['username']));
55 | }
56 | });
57 | } else {
58 | dispath(setUsername(null));
59 | dispath(setUser(null));
60 | }
61 |
62 | return unsubscribe;
63 | }, [user]);
64 |
65 | return basicLayout ? (
66 | // eslint-disable-next-line react/jsx-no-useless-fragment
67 | <>{children}>
68 | ) : (
69 |
70 | {/* background */}
71 |
76 |
77 | {/* heading */}
78 |
79 |
{heading}
80 | {subHeading && (
81 |
82 | {subHeading}
83 |
84 | )}
85 |
86 | {/* content */}
87 |
88 | {children}
89 |
90 |
91 |
92 | );
93 | };
94 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/buttons/button.component.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import classNames from 'classnames';
3 | import Link from 'next/link';
4 | import { ButtonHTMLAttributes, ReactNode } from 'react';
5 |
6 | type ButtonProps = {
7 | primary?: boolean;
8 | className?: string;
9 | translate?: string;
10 | sizeClass?: string;
11 | fontSize?: string;
12 | loading?: boolean;
13 | disabled?: boolean;
14 | type?: ButtonHTMLAttributes['type'];
15 | href?: string;
16 | targetBlank?: boolean;
17 | onClick?: () => void;
18 | children?: ReactNode;
19 | };
20 |
21 | export const Button = ({
22 | primary,
23 | className = 'text-neutral-700 dark:text-neutral-200',
24 | translate = '',
25 | sizeClass = 'px-4 py-3 sm:px-6',
26 | fontSize = 'text-sm sm:text-base font-medium',
27 | disabled = false,
28 | href,
29 | children,
30 | targetBlank,
31 | type,
32 | loading,
33 | // eslint-disable-next-line @typescript-eslint/no-empty-function
34 | onClick = () => {},
35 | }: ButtonProps) => {
36 | const classes = classNames(
37 | 'relative h-auto inline-flex items-center justify-center rounded-full transition-colors',
38 | fontSize,
39 | sizeClass,
40 | translate,
41 | {
42 | 'disabled:bg-opacity-70 bg-primary-6000 hover:bg-primary-700 text-neutral-50':
43 | primary,
44 | },
45 | className,
46 | 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-6000 dark:focus:ring-offset-0',
47 | );
48 |
49 | if (href) {
50 | const externalLinkRegex = /^http/;
51 | return externalLinkRegex.test(href) ? (
52 |
61 | {children || `External Link`}
62 |
63 | ) : (
64 |
65 | {children || `Link`}
66 |
67 | );
68 | }
69 | return (
70 |
102 | );
103 | };
104 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/post-filter-list-box.component.tsx:
--------------------------------------------------------------------------------
1 | import { Listbox, Transition } from '@headlessui/react';
2 | import React, { Fragment, useState } from 'react';
3 | import { FiCheck, FiChevronDown } from 'react-icons/fi';
4 |
5 | import { Button } from '../buttons';
6 |
7 | // TODO: move this to a better place
8 | const FILTERS = [
9 | { id: 0, name: 'Most Recent' },
10 | { id: 1, name: 'Most Liked' },
11 | { id: 2, name: 'Most Discussed' },
12 | { id: 3, name: 'Most Viewed' },
13 | ];
14 |
15 | export const PostFilterListBox: React.FC = () => {
16 | const [selected, setSelected] = useState(FILTERS[0]);
17 | return (
18 |
19 |
20 |
21 |
33 |
34 |
40 |
41 | {FILTERS.map((item, index) => (
42 |
45 | `${
46 | active
47 | ? 'text-primary-700 dark:text-neutral-200 bg-primary-50 dark:bg-neutral-700'
48 | : ''
49 | } cursor-default select-none relative py-2 pl-10 pr-4`
50 | }
51 | value={item}
52 | >
53 | {({ selected }) => (
54 | <>
55 |
60 | {item.name}
61 |
62 | {selected ? (
63 |
64 |
65 |
66 | ) : null}
67 | >
68 | )}
69 |
70 | ))}
71 |
72 |
73 |
74 |
75 | );
76 | };
77 |
--------------------------------------------------------------------------------
/apps/bloggo/styles/styles.scss:
--------------------------------------------------------------------------------
1 | @import 'tailwind';
2 |
3 | @import 'fonts';
4 | @import 'polyfills';
5 | @import 'theme-variables';
6 |
7 | .root {
8 | overflow: hidden;
9 | }
10 |
11 | .nc-will-change-transform {
12 | will-change: transform;
13 | }
14 | .nc-will-change-top {
15 | will-change: top;
16 | }
17 |
18 | .nc-card-title {
19 | @apply transition-colors hover:text-primary-900 dark:hover:text-primary-300 duration-75;
20 | }
21 |
22 | .nc-PostCardCommentBtn,
23 | .bookmark-post-button,
24 | .like-post-button,
25 | .nc-CommentCardLikeReply > button {
26 | &:hover {
27 | span {
28 | color: inherit;
29 | }
30 | }
31 | }
32 |
33 | .react-tabs__tab--selected {
34 | button {
35 | @apply bg-primary-500 text-secondary-50;
36 | }
37 | }
38 |
39 | .nc-box-has-hover {
40 | @apply bg-white rounded-3xl border border-neutral-200 border-opacity-70 hover:bg-neutral-50 hover:shadow-xl hover:border-transparent transition-shadow;
41 | }
42 |
43 | .nc-dark-box-bg-has-hover {
44 | @apply dark:bg-neutral-800 dark:bg-opacity-30 dark:border dark:border-neutral-700 dark:hover:bg-neutral-800 dark:hover:shadow-2xl;
45 | }
46 |
47 | .nc-dark-box-bg {
48 | @apply dark:bg-neutral-800 dark:bg-opacity-30 dark:border dark:border-neutral-700 dark:shadow-2xl;
49 | }
50 |
51 | .nc-origin-100 {
52 | transform-origin: 100% 50% 0px;
53 | }
54 | .nc-origin-50 {
55 | transform-origin: 50% 50% 0px;
56 | }
57 |
58 | .nc-PostFeaturedMedia {
59 | .glide__bullet--active {
60 | @apply bg-white bg-opacity-100;
61 | }
62 | }
63 |
64 | .lds-ellipsis {
65 | display: inline-block;
66 | position: relative;
67 | width: 80px;
68 | height: 80px;
69 | }
70 | .lds-ellipsis div {
71 | position: absolute;
72 | top: 33px;
73 | width: 13px;
74 | height: 13px;
75 | border-radius: 50%;
76 | background: #fff;
77 | animation-timing-function: cubic-bezier(0, 1, 1, 0);
78 | }
79 | .lds-ellipsis div:nth-child(1) {
80 | left: 8px;
81 | animation: lds-ellipsis1 0.6s infinite;
82 | }
83 | .lds-ellipsis div:nth-child(2) {
84 | left: 8px;
85 | animation: lds-ellipsis2 0.6s infinite;
86 | }
87 | .lds-ellipsis div:nth-child(3) {
88 | left: 32px;
89 | animation: lds-ellipsis2 0.6s infinite;
90 | }
91 | .lds-ellipsis div:nth-child(4) {
92 | left: 56px;
93 | animation: lds-ellipsis3 0.6s infinite;
94 | }
95 | @keyframes lds-ellipsis1 {
96 | 0% {
97 | transform: scale(0);
98 | }
99 | 100% {
100 | transform: scale(1);
101 | }
102 | }
103 | @keyframes lds-ellipsis3 {
104 | 0% {
105 | transform: scale(1);
106 | }
107 | 100% {
108 | transform: scale(0);
109 | }
110 | }
111 | @keyframes lds-ellipsis2 {
112 | 0% {
113 | transform: translate(0, 0);
114 | }
115 | 100% {
116 | transform: translate(24px, 0);
117 | }
118 | }
119 | .nc-animation-spin {
120 | animation: myspin 20s linear infinite;
121 | animation-play-state: paused;
122 |
123 | &.playing {
124 | animation-play-state: running;
125 | }
126 |
127 | @keyframes myspin {
128 | from {
129 | transform: rotate(0deg);
130 | }
131 | to {
132 | transform: rotate(360deg);
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/toast.component.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react';
2 | import React, { Fragment } from 'react';
3 | import {
4 | FiAlertCircle,
5 | FiCheckCircle,
6 | FiInfo,
7 | FiX,
8 | FiXCircle,
9 | } from 'react-icons/fi';
10 |
11 | export type ToastProps = {
12 | isOpen: boolean;
13 | toggle: () => void;
14 | text: {
15 | heading: string;
16 | body: string;
17 | };
18 | type?: 'success' | 'error' | 'info' | 'warning';
19 | };
20 |
21 | export const Toast: React.FC = ({
22 | isOpen,
23 | toggle,
24 | text: { heading, body },
25 | type = 'info',
26 | }) => {
27 | const getToastIcon = (type: 'success' | 'error' | 'info' | 'warning') => {
28 | switch (type) {
29 | case 'success':
30 | return (
31 |
35 | );
36 | case 'error':
37 | return (
38 |
39 | );
40 | case 'info':
41 | return ;
42 | case 'warning':
43 | return (
44 |
48 | );
49 | }
50 | };
51 | return (
52 |
56 |
57 |
67 |
68 |
69 |
70 |
{getToastIcon(type)}
71 |
72 |
{heading}
73 |
{body}
74 |
75 |
76 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | );
91 | };
92 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bloggo",
3 | "version": "0.1.0",
4 | "license": "MIT",
5 | "author": {
6 | "name": "Sal Anvarov",
7 | "email": "msalanvarov@gmail.com",
8 | "url": "https://www.sal-anvarov.com"
9 | },
10 | "scripts": {
11 | "start": "nx serve",
12 | "build": "nx build",
13 | "test": "nx test"
14 | },
15 | "private": true,
16 | "dependencies": {
17 | "@glidejs/glide": "^3.5.2",
18 | "@headlessui/react": "^1.4.3",
19 | "@nx/node": "16.7.4",
20 | "@reduxjs/toolkit": "1.9.3",
21 | "@tailwindcss/aspect-ratio": "^0.4.0",
22 | "@tailwindcss/forms": "^0.4.0",
23 | "@tailwindcss/line-clamp": "^0.3.1",
24 | "@tailwindcss/typography": "^0.5.0",
25 | "@trivago/prettier-plugin-sort-imports": "^3.1.1",
26 | "@vercel/analytics": "^1.0.2",
27 | "autoprefixer": "10.4.2",
28 | "classnames": "^2.3.1",
29 | "core-js": "^3.6.5",
30 | "draft-js": "^0.11.7",
31 | "draftjs-md-converter": "^1.5.2",
32 | "firebase": "^9.6.3",
33 | "formik": "^2.2.9",
34 | "js-sha256": "^0.9.0",
35 | "lodash": "^4.17.21",
36 | "moment": "^2.29.1",
37 | "moment-timezone": "^0.5.34",
38 | "next": "13.3.0",
39 | "react": "18.2.0",
40 | "react-dom": "18.2.0",
41 | "react-draft-wysiwyg": "^1.14.7",
42 | "react-firebase-hooks": "^4.0.2",
43 | "react-icons": "^4.3.1",
44 | "react-is": "18.0.0",
45 | "react-markdown": "^7.1.2",
46 | "react-moment": "^1.1.1",
47 | "react-redux": "8.0.5",
48 | "react-tabs": "^3.2.3",
49 | "react-tooltip": "^4.2.21",
50 | "redux-persist": "^6.0.0",
51 | "regenerator-runtime": "0.13.7",
52 | "remark-gfm": "^3.0.1",
53 | "tailwindcss": "^3.0.15",
54 | "tslib": "^2.0.0",
55 | "yup": "^0.32.11"
56 | },
57 | "devDependencies": {
58 | "@nrwl/js": "16.7.4",
59 | "@nx/cypress": "16.7.4",
60 | "@nx/eslint-plugin": "16.7.4",
61 | "@nx/jest": "16.7.4",
62 | "@nx/linter": "16.7.4",
63 | "@nx/next": "16.7.4",
64 | "@nx/react": "16.7.4",
65 | "@nx/web": "16.7.4",
66 | "@nx/workspace": "16.7.4",
67 | "@testing-library/react": "14.0.0",
68 | "@types/glidejs__glide": "^3.4.1",
69 | "@types/jest": "29.4.4",
70 | "@types/node": "18.14.2",
71 | "@types/react": "18.2.14",
72 | "@types/react-dom": "18.2.6",
73 | "@types/react-draft-wysiwyg": "^1.13.4",
74 | "@types/react-tabs": "^2.3.4",
75 | "@types/yup": "^0.29.13",
76 | "@typescript-eslint/eslint-plugin": "5.62.0",
77 | "@typescript-eslint/parser": "5.62.0",
78 | "babel-jest": "29.4.3",
79 | "cypress": "^9.1.0",
80 | "eslint": "8.46.0",
81 | "eslint-config-next": "13.1.1",
82 | "eslint-config-prettier": "8.1.0",
83 | "eslint-plugin-cypress": "2.14.0",
84 | "eslint-plugin-import": "2.27.5",
85 | "eslint-plugin-jsx-a11y": "6.7.1",
86 | "eslint-plugin-react": "7.32.2",
87 | "eslint-plugin-react-hooks": "4.6.0",
88 | "jest": "29.4.3",
89 | "jest-environment-jsdom": "28.1.3",
90 | "nx": "16.7.4",
91 | "nx-cloud": "16.3.0",
92 | "postcss": "8.4.21",
93 | "prettier": "2.8.8",
94 | "sass": "1.55.0",
95 | "ts-jest": "29.1.1",
96 | "ts-node": "10.9.1",
97 | "typescript": "5.1.6"
98 | },
99 | "peerDependencies": {
100 | "postcss": "^8.4.5"
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/image/image-container.component.tsx:
--------------------------------------------------------------------------------
1 | import React, { ImgHTMLAttributes, useEffect, useRef, useState } from 'react';
2 |
3 | import { AppState, useAppSelector } from '@bloggo/redux';
4 |
5 | import checkInViewIntersectionObserver from './image-inview-port.util';
6 | import placeholderImageLargeDarkHPng from './placeholders/placeholder-large-dark-h.png';
7 | import placeholderImageLargeDarkPng from './placeholders/placeholder-large-dark.png';
8 | import placeholderImageLargeHPng from './placeholders/placeholder-large-h.png';
9 | import placeholderImageLargePng from './placeholders/placeholder-large.png';
10 |
11 | interface ImageContainerProps extends ImgHTMLAttributes {
12 | containerClassName?: string;
13 | prevImageHorizontal?: boolean;
14 | }
15 |
16 | type StaticImageData = {
17 | src: string;
18 | height: number;
19 | width: number;
20 | placeholder?: string;
21 | };
22 |
23 | export const ImageContainer: React.FC = ({
24 | containerClassName = '',
25 | alt = 'container',
26 | src = '',
27 | prevImageHorizontal = false,
28 | className = 'object-cover w-full h-full',
29 | ...rest
30 | }) => {
31 | const { darkMode } = useAppSelector((state: AppState) => state.layout);
32 | let isMounted = false;
33 | const containerDivElRef = useRef(null);
34 | let imageEl: HTMLImageElement | null = null;
35 | const placeholderImage = darkMode
36 | ? prevImageHorizontal
37 | ? placeholderImageLargeDarkHPng
38 | : placeholderImageLargeDarkPng
39 | : prevImageHorizontal
40 | ? placeholderImageLargeHPng
41 | : placeholderImageLargePng;
42 |
43 | const [__src, set__src] = useState(
44 | placeholderImage,
45 | );
46 | const [imageLoaded, setImageLoaded] = useState(false);
47 |
48 | const _initActions = async () => {
49 | set__src(placeholderImage);
50 | _checkInViewPort();
51 | };
52 |
53 | const _checkInViewPort = () => {
54 | if (!containerDivElRef.current) return;
55 | checkInViewIntersectionObserver({
56 | target: containerDivElRef.current as any,
57 | distanceFromEnd: 0,
58 | callback: _imageOnViewPort,
59 | });
60 | };
61 |
62 | const _imageOnViewPort = () => {
63 | if (!src) {
64 | _handleImageLoaded();
65 | return true;
66 | }
67 | imageEl = new Image();
68 | if (imageEl) {
69 | imageEl.src = src;
70 | imageEl.addEventListener('load', _handleImageLoaded);
71 | }
72 | return true;
73 | };
74 |
75 | const _handleImageLoaded = () => {
76 | if (!isMounted) return;
77 | setImageLoaded(true);
78 | set__src(src);
79 | };
80 |
81 | useEffect(() => {
82 | isMounted = true;
83 | _initActions();
84 | return () => {
85 | isMounted = false;
86 | };
87 | }, [src]);
88 |
89 | useEffect(() => {
90 | if (!imageLoaded) {
91 | set__src(placeholderImage);
92 | }
93 | }, [darkMode]);
94 |
95 | return (
96 |
97 | {__src ? (
98 |

104 | ) : (
105 |
108 | )}
109 |
110 | );
111 | };
112 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/cards/author-card.component.tsx:
--------------------------------------------------------------------------------
1 | import { random } from 'lodash';
2 | import React from 'react';
3 | import { FiChevronRight } from 'react-icons/fi';
4 | import Moment from 'react-moment';
5 |
6 | import { IFirestoreUsernameData } from '@bloggo/redux';
7 |
8 | import { Avatar } from '../avatar.component';
9 | import { ImageContainer } from '../image';
10 | import { Link } from '../link.component';
11 |
12 | interface AuthorCardProps {
13 | className?: string;
14 | author: IFirestoreUsernameData;
15 | }
16 |
17 | // TODO: remove this
18 | const backgroundImageOptions: string[] = [
19 | 'https://images.pexels.com/photos/912410/pexels-photo-912410.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
20 | 'https://images.pexels.com/photos/7354542/pexels-photo-7354542.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
21 | 'https://images.pexels.com/photos/3651577/pexels-photo-3651577.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
22 | 'https://images.pexels.com/photos/4064835/pexels-photo-4064835.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
23 | 'https://images.pexels.com/photos/3330118/pexels-photo-3330118.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
24 | 'https://images.pexels.com/photos/4066850/pexels-photo-4066850.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
25 | 'https://images.pexels.com/photos/931887/pexels-photo-931887.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
26 | 'https://images.pexels.com/photos/7175377/pexels-photo-7175377.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
27 | 'https://images.pexels.com/photos/7663205/pexels-photo-7663205.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
28 | 'https://images.pexels.com/photos/973505/pexels-photo-973505.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260',
29 | ];
30 |
31 | export const AuthorCard: React.FC = ({
32 | className = '',
33 | author,
34 | }) => {
35 | const { username, createdAt } = author;
36 |
37 | const generateRandomBackgroundImage = () =>
38 | backgroundImageOptions[random(backgroundImageOptions.length - 1)];
39 |
40 | return (
41 |
46 |
47 |
48 |
52 |
53 |
54 |
55 | 1 post
56 |
57 |
58 |
59 |
60 |
61 |
68 |
69 |
70 | @{username}
71 |
72 |
75 | Onboarded on {createdAt}
76 |
77 |
78 |
79 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/portal/user-posts-list.component.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DocumentData,
3 | QueryDocumentSnapshot,
4 | collection,
5 | orderBy,
6 | query,
7 | } from 'firebase/firestore';
8 | import React from 'react';
9 | import { useCollection } from 'react-firebase-hooks/firestore';
10 |
11 | import { IFirestorePostData, db } from '@bloggo/redux';
12 |
13 | import { ImageContainer } from '../image';
14 | import { Link } from '../link.component';
15 |
16 | type UserPostsListProps = {
17 | uid?: string;
18 | };
19 |
20 | export const UserPostsList: React.FC = ({ uid }) => {
21 | const postsRef = collection(db, `/users/${uid ?? ''}/posts`);
22 | const q = query(postsRef, orderBy('createdAt'));
23 | const [
24 | posts = { docs: [] as QueryDocumentSnapshot[] },
25 | loading,
26 | error,
27 | ] = useCollection(q);
28 |
29 | if (loading) {
30 | console.log('Loading posts...');
31 | }
32 | if (error) {
33 | console.error(error);
34 | return Error...
;
35 | }
36 | return (
37 |
38 | {posts.docs.length >= 1 ? (
39 | posts.docs.map((post) => {
40 | const postData = post.data() as IFirestorePostData;
41 | return (
42 |
43 |
44 |
45 |
49 |
50 |
51 |
56 | {postData.title}
57 |
58 |
59 |
60 |
61 | |
62 |
63 | {postData.published ? (
64 |
65 | Published
66 |
67 | ) : (
68 |
69 | Draft
70 |
71 | )}
72 | |
73 |
74 |
79 | Edit
80 |
81 | {` | `}
82 |
87 | Delete
88 |
89 | |
90 |
91 | );
92 | })
93 | ) : (
94 | No posts to display
95 | )}
96 |
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/cards/small-post-preview-card.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Moment from 'react-moment';
3 |
4 | import { IFirestorePostData } from '@bloggo/redux';
5 |
6 | import { Avatar } from '../avatar.component';
7 | import { BadgeList } from '../badge';
8 | import { ImageContainer } from '../image';
9 | import { Link } from '../link.component';
10 | import { PostPreviewCardActionButtons } from '../post/post-preview-card-action-buttons.component';
11 |
12 | type SmallPostPreviewCardProps = {
13 | className?: string;
14 | post: IFirestorePostData;
15 | };
16 |
17 | export const SmallPostPreviewCard: React.FC = ({
18 | className = 'h-full',
19 | post,
20 | }) => {
21 | return (
22 |
25 |
30 |
31 |
32 |
33 |
34 |
39 | {post.title}
40 |
41 |
42 |
43 | {post.description}
44 |
45 |
48 |
53 |
59 |
60 |
61 | @{post.username}
62 |
63 |
64 |
65 |
66 | ·
67 |
68 |
69 | {post.createdAt}
70 |
71 |
72 |
73 |
84 |
85 |
86 |
91 |
97 |
98 |
99 | );
100 | };
101 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/buttons/like-post-button.component.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { doc, increment, writeBatch } from 'firebase/firestore';
3 | import React, { useState } from 'react';
4 | import { useDocument } from 'react-firebase-hooks/firestore';
5 |
6 | import { AppState, db, useAppSelector } from '@bloggo/redux';
7 |
8 | type LikePostButtonProps = {
9 | className?: string;
10 | likes: number;
11 | uid: string;
12 | postId: string;
13 | };
14 |
15 | const formatLikeNumber = (number: number): string => {
16 | let formattedNumber = '';
17 | if (number < 1000) {
18 | formattedNumber = number.toString();
19 | } else if (number < 1000000) {
20 | formattedNumber = (number / 1000).toFixed(1) + 'K';
21 | }
22 | return formattedNumber;
23 | };
24 |
25 | export const LikePostButton: React.FC = ({
26 | className = 'px-3 h-8 text-xs',
27 | likes,
28 | uid,
29 | postId,
30 | }) => {
31 | const { user } = useAppSelector((state: AppState) => state.user);
32 | const likeRef = doc(db, `${postId}/likes/${uid}`);
33 | const [likeDoc] = useDocument(likeRef);
34 | const [isLikePostButtonClicked, setIsLikePostButtonClicked] =
35 | useState(likeDoc?.exists() ?? false);
36 |
37 | // Create a user-to-post relationship
38 | const addLike = async () => {
39 | const uid = user?.uid;
40 | if (uid) {
41 | const batch = writeBatch(db);
42 |
43 | batch.update(doc(db, postId), { likes: increment(1) });
44 | batch.set(likeRef, { uid });
45 |
46 | await batch.commit();
47 | }
48 | };
49 |
50 | // Remove a user-to-post relationship
51 | const removeLike = async () => {
52 | const batch = writeBatch(db);
53 |
54 | batch.update(doc(db, postId), { likes: increment(-1) });
55 | batch.delete(likeRef);
56 |
57 | await batch.commit();
58 | };
59 |
60 | const isLiked = () => {
61 | return isLikePostButtonClicked;
62 | };
63 | const getLikeCount = () => {
64 | if (isLikePostButtonClicked) {
65 | return likes + 1;
66 | }
67 | return likes;
68 | };
69 | const handleOnLikeButtonClick = () => {
70 | if (isLiked()) {
71 | removeLike();
72 | setIsLikePostButtonClicked(false);
73 | } else {
74 | addLike();
75 | setIsLikePostButtonClicked(true);
76 | }
77 | };
78 | return (
79 |
117 | );
118 | };
119 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/cards/large-post-preview-card.component.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Moment from 'react-moment';
3 |
4 | import { IFirestorePostData } from '@bloggo/redux';
5 |
6 | import { Avatar } from '../avatar.component';
7 | import { BadgeList } from '../badge';
8 | import { SharePostButtonContainer } from '../buttons/share-post-button-container-component';
9 | import { ImageContainer } from '../image';
10 | import { Link } from '../link.component';
11 | import { PostPreviewCardActionButtons } from '../post/post-preview-card-action-buttons.component';
12 |
13 | type LargePostPreviewCardProps = {
14 | className?: string;
15 | post: IFirestorePostData;
16 | size?: 'normal' | 'large';
17 | };
18 |
19 | const LargePostPreviewCard: React.FC = ({
20 | className = 'h-full',
21 | size = 'normal',
22 | post,
23 | }) => {
24 | return (
25 |
28 |
29 |
35 |
36 |
37 |
38 |
43 |
44 |
45 |
46 |
47 |
52 |
57 | {post.title}
58 |
59 |
60 |
61 | {post.description}
62 |
63 |
64 |
69 |
76 |
77 |
80 | @{post.username}
81 |
82 |
85 |
86 | {post.createdAt}
87 |
88 |
89 |
90 |
91 |
102 |
103 |
104 | );
105 | };
106 |
107 | export default LargePostPreviewCard;
108 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/post/latest-posts-preview.component.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import { IFirestorePostData } from '@bloggo/redux';
4 |
5 | import { Button } from '../buttons';
6 | import LargePostPreviewCard from '../cards/large-post-preview-card.component';
7 | import { SmallPostPreviewCard } from '../cards/small-post-preview-card.component';
8 | import { SectionHeading } from '../section-heading.component';
9 |
10 | type LatestPostPreviewProps = {
11 | className?: string;
12 | heading?: string;
13 | // TODO: create data model for categories
14 | categories: string[];
15 | posts: IFirestorePostData[];
16 | onGetMorePostsClick?: () => void;
17 | loading: boolean;
18 | endOfPosts: boolean;
19 | };
20 |
21 | export const LatestPostPreview: React.FC = ({
22 | className = '',
23 | heading = 'Latest Articles 🎈',
24 | categories,
25 | posts,
26 | onGetMorePostsClick,
27 | loading,
28 | endOfPosts,
29 | }) => {
30 | // TODO: implement proper category filtering
31 | // let timeOut: NodeJS.Timeout | null = null;
32 | const [categorySelected, setCategorySelected] = useState(
33 | categories[0],
34 | );
35 | const handleClickTab = (category: string) => {
36 | if (category === categorySelected) {
37 | return;
38 | }
39 | // setIsLoading(true);
40 | setCategorySelected(category);
41 | // if (timeOut) {
42 | // clearTimeout(timeOut);
43 | // }
44 | // timeOut = setTimeout(() => {
45 | // setIsLoading(false);
46 | // }, 600);
47 | };
48 | if (loading) {
49 | console.log('Loader');
50 | }
51 | return (
52 |
53 |
54 |
{heading}
55 |
56 |
74 |
75 |
89 |
90 |
91 |
92 | {posts.length < 1 && No posts to be displayed}
93 |
94 | {posts[0] &&
}
95 |
96 | {posts
97 | .filter((_, i) => i < 4 && i > 0)
98 | .map((item, index) => (
99 |
100 | ))}
101 |
102 |
103 |
104 | );
105 | };
106 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { GetServerSideProps } from 'next';
3 | import React, { useState } from 'react';
4 |
5 | import {
6 | IFirestorePostData,
7 | IFirestoreUsernameData,
8 | fromMillis,
9 | getPostsByLikesWithLimit,
10 | getPostsWithLimit,
11 | getPostsWithLimitStartingAt,
12 | getUsernamesOrderedByDateWithLimit,
13 | } from '@bloggo/redux';
14 | import {
15 | AppLayout,
16 | LatestPostPreview,
17 | Metatags,
18 | MostPopularPostsSlider,
19 | NewestAuthorsSlider,
20 | } from '@bloggo/ui';
21 |
22 | interface IndexPageProps {
23 | latestPosts: IFirestorePostData[];
24 | mostLikedPosts: IFirestorePostData[];
25 | latestAuthors: IFirestoreUsernameData[];
26 | }
27 |
28 | // Max post to query per page
29 | const LIMIT = 10;
30 |
31 | export const getServerSideProps: GetServerSideProps<
32 | IndexPageProps
33 | > = async () => {
34 | const [latestPosts, mostLikedPosts, authors] = await Promise.all([
35 | getPostsWithLimit(LIMIT),
36 | getPostsByLikesWithLimit(3),
37 | getUsernamesOrderedByDateWithLimit(5),
38 | ]);
39 |
40 | return {
41 | props: {
42 | latestPosts: latestPosts as IFirestorePostData[],
43 | mostLikedPosts: mostLikedPosts as IFirestorePostData[],
44 | latestAuthors: authors as unknown as IFirestoreUsernameData[],
45 | },
46 | };
47 | };
48 |
49 | const Index: React.FC = ({
50 | latestPosts,
51 | mostLikedPosts,
52 | latestAuthors,
53 | }) => {
54 | const [postsOrderedByDate, setPostsOrderedByDate] =
55 | useState(latestPosts);
56 | const [loading, setLoading] = useState(false);
57 | const [endOfPosts, setEndOfPosts] = useState(false);
58 |
59 | console.log(latestAuthors);
60 |
61 | const getMorePosts = async () => {
62 | setLoading(true);
63 | const last = postsOrderedByDate[postsOrderedByDate.length - 1];
64 |
65 | const cursor =
66 | typeof last.createdAt === 'number'
67 | ? fromMillis(last.createdAt)
68 | : last.createdAt;
69 |
70 | const newPosts = await getPostsWithLimitStartingAt(LIMIT, cursor);
71 |
72 | setPostsOrderedByDate(
73 | postsOrderedByDate.concat(newPosts as IFirestorePostData[]),
74 | );
75 | setLoading(false);
76 |
77 | if (newPosts.length < LIMIT) {
78 | setEndOfPosts(true);
79 | }
80 | };
81 |
82 | return (
83 | <>
84 |
85 |
86 |
87 | {/* Glassmorphism background */}
88 |
89 |
90 |
91 |
92 |
93 |
94 |
98 |
99 | {/* background */}
100 |
106 |
111 |
112 |
113 |
119 |
120 |
121 |
122 | >
123 | );
124 | };
125 |
126 | export default Index;
127 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Bloggo - Medium Clone
2 |
3 |
4 |
5 |
6 |
7 |
8 | A full-stack Node.js application built with Next.js, and Firebase made as a clone to Medium.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Table of Contents:
17 |
18 | 1. [Description](#-description)
19 | 2. [Prerequisites](#%EF%B8%8F-prerequisites)
20 | 3. [Deployment](#-deployment)
21 | 4. [Environment Configuration](#-environment-configuration)
22 | 5. [Testing](#-testing)
23 |
24 | 🔎 This repo was created with [Nx](https://nx.dev/).
25 |
26 | ### 📚 Description
27 |
28 | This application was built to mimic the functionalities of the Medium blogging website but with Firebase as the api. Deployed with authentication/authorization, logging, crud features and database persistence out of the box.
29 |
30 | ---
31 |
32 | ### 🛠️ Prerequisites
33 |
34 | #### Non Docker
35 |
36 | - Please make sure to have [Node.js](https://nodejs.org/en/download/) (16+) locally by downloading the Javascript runtime via `brew`, `choco`, or `apt-get`.
37 |
38 | - Please make sure to have Firebase configured either locally or onboarded on GCP cloud by following this [guide](https://medium.com/codex/learn-the-basics-and-get-started-with-firebase-an-app-development-platform-backed-by-google-6c27b3be1004).
39 |
40 | #### Docker 🐳
41 |
42 | - Please make sure to have [Docker Desktop](https://www.docker.com/products/docker-desktop/) operational to quickly compose the required dependencies. Then follow the docker procedure outlined below.
43 |
44 | ---
45 |
46 | ### 🚀 Deployment
47 |
48 | #### Manual Deployment without Docker
49 |
50 | - Clone the repo via `git clone https://github.com/msanvarov/bloggo`.
51 |
52 | - Download dependencies via `npm i` or `yarn`.
53 |
54 | - Reconfigure Firebase with the Firebase CLI via `firebase init`.
55 |
56 | - Create a **.env file** via the `cp apps/bloggo/.env.example apps/bloggo/.env` command and replace the existing environment variable placeholders with valid responses.
57 |
58 | - Start the api in development mode by using `npm run start` (the **ui** will be exposed on http://localhost:4200).
59 |
60 | #### Deploying with Docker 🐳
61 |
62 | - Execute the following command in-app directory:
63 |
64 | ```bash
65 | # creates and loads the docker container in detached mode with the required configuration
66 | $ docker-compose up -d
67 | ```
68 |
69 | - The following command will download dependencies and execute the web application on http://localhost:80 (deployed behind a Nginx reverse proxy).
70 |
71 | ---
72 |
73 | ### 🔒 Environment Configuration
74 |
75 | By default, the application comes with a config module that can read in every environment variable from the `.env` file.
76 |
77 | **APP_ENV** - the application environment to execute as, either in development or production. Determines the type of logging options to utilize. Options: `development` or `production`.
78 |
79 | **FIREBASE_*** - the firebase config details that can be fetched when creating the [SDK for the app](https://firebase.google.com/docs/web/setup).
80 |
81 | ---
82 |
83 | ### ✅ Testing
84 |
85 | #### Docker 🐳
86 |
87 | ```bash
88 | # Start the docker container if it's not running
89 | $ docker start bloggo
90 |
91 | # unit tests
92 | $ docker exec -it bloggo npm run test
93 |
94 | # test api against postman collection
95 | $ docker exec -it bloggo npm run test:postman
96 |
97 | ```
98 |
99 | #### Non-Docker
100 |
101 | ```bash
102 | # execute test
103 | $ npm run test
104 |
105 | ```
106 |
107 | ---
108 |
109 | ### 🏗️ Progress
110 |
111 | | Branches | Status |
112 | | ------------------------------------------------------: | :----- |
113 | | [main](https://github.com/msanvarov/bloggo) | ✅ |
114 | | [feat/\*](https://github.com/msanvarov/bloggo/branches) | 🚧 |
115 |
116 | ---
117 |
118 | ### 👥 Help
119 |
120 | PRs are appreciated, I fully rely on the passion ❤️ of the OS developers.
121 |
122 | ---
123 |
124 | ## License
125 |
126 | This starter API is [MIT licensed](LICENSE).
127 |
128 | [Author](https://sal-anvarov.com/)
129 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/image-uploader.component.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DocumentData,
3 | DocumentReference,
4 | serverTimestamp,
5 | updateDoc,
6 | } from 'firebase/firestore';
7 | import { getDownloadURL, ref, uploadBytesResumable } from 'firebase/storage';
8 | import { sha256 } from 'js-sha256';
9 | import React, { useState } from 'react';
10 |
11 | import {
12 | AppState,
13 | STATE_CHANGED,
14 | storage,
15 | useAppSelector,
16 | } from '@bloggo/redux';
17 |
18 | import { ImageContainer } from './image/image-container.component';
19 |
20 | type ImageUploaderProps = {
21 | postRef: DocumentReference;
22 | };
23 |
24 | export const ImageUploader: React.FC = ({ postRef }) => {
25 | const { user } = useAppSelector((state: AppState) => state.user);
26 | const [uploading, setUploading] = useState(false);
27 | const [progress, setProgress] = useState('0');
28 | const [imagePreviewURL, setImagePreviewURL] = useState(null);
29 |
30 | // TODO: Hash the whole file before uploading to remove duplicate images
31 | const uploadFile = async (e: React.FormEvent) => {
32 | if (e.currentTarget.files) {
33 | // Get the file.
34 | const file = Array.from(e.currentTarget.files)[0];
35 | const extension = file.type.split('/')[1];
36 |
37 | if (user?.uid) {
38 | // Makes reference to the storage bucket location
39 | const fileRef = ref(
40 | storage,
41 | `uploads/${user.uid}/${sha256(file.name)}.${extension}`,
42 | );
43 | setUploading(true);
44 |
45 | // Starts the upload
46 | const task = uploadBytesResumable(fileRef, file);
47 |
48 | // Listen to updates to upload task
49 | task.on(STATE_CHANGED, (snapshot) => {
50 | const pct = (
51 | (snapshot.bytesTransferred / snapshot.totalBytes) *
52 | 100
53 | ).toFixed(0);
54 | setProgress(pct);
55 | });
56 |
57 | // Get downloadURL AFTER task resolves (Note: this is not a native Promise)
58 | task
59 | .then(() => getDownloadURL(fileRef))
60 | .then((url) => {
61 | // Setting the thumbnail image
62 | updateDoc(postRef, {
63 | thumbnail: url,
64 | updatedAt: serverTimestamp(),
65 | });
66 | setImagePreviewURL(url as string);
67 | setUploading(false);
68 | });
69 | }
70 | }
71 | };
72 | return (
73 | <>
74 |
75 |
76 |
90 |
91 |
105 |
or drag and drop
106 |
107 |
PNG, JPG, GIF up to 2MB
108 | {uploading &&
{progress}%
}
109 |
110 |
111 | {imagePreviewURL && (
112 |
113 |
119 |
120 | )}
121 | >
122 | );
123 | };
124 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/[username]/[slug].tsx:
--------------------------------------------------------------------------------
1 | import { doc } from 'firebase/firestore';
2 | import { GetStaticPaths, GetStaticProps } from 'next';
3 | import React from 'react';
4 | import { useDocumentData } from 'react-firebase-hooks/firestore';
5 |
6 | import {
7 | AppState,
8 | IFirestorePostData,
9 | db,
10 | getPostBySlugForUser,
11 | getPostPaths,
12 | getUserDataFromUsername,
13 | useAppSelector,
14 | } from '@bloggo/redux';
15 | import {
16 | AppLayout,
17 | BadgeList,
18 | ImageContainer,
19 | Metatags,
20 | PostContent,
21 | PostEntryMetadata,
22 | } from '@bloggo/ui';
23 |
24 | interface UserPostPageProps {
25 | path: string;
26 | post: IFirestorePostData | null;
27 | }
28 |
29 | export const getStaticProps: GetStaticProps = async ({
30 | params,
31 | }) => {
32 | const { username, slug } = params;
33 | const userDoc = await getUserDataFromUsername(username);
34 |
35 | let post: IFirestorePostData;
36 | let path: string;
37 |
38 | if (userDoc) {
39 | const [postPath, postData] = await getPostBySlugForUser(
40 | userDoc.ref.path,
41 | slug,
42 | );
43 | post = postData as IFirestorePostData | null;
44 | path = postPath;
45 | }
46 |
47 | return {
48 | props: { post, path },
49 | revalidate: 100,
50 | };
51 | };
52 |
53 | export const getStaticPaths: GetStaticPaths = async () => {
54 | // TODO: move to firebase function
55 | const paths = await getPostPaths();
56 | return {
57 | // must be in this format:
58 | // paths: [
59 | // { params: { username, slug }}
60 | // ],
61 | paths,
62 | fallback: 'blocking',
63 | };
64 | };
65 |
66 | const UserPost: React.FC = ({
67 | path,
68 | post: prefetchedPost,
69 | }) => {
70 | const { user } = useAppSelector((state: AppState) => state.user);
71 | const postRef = doc(db, path);
72 | const [realtimePost] = useDocumentData(postRef);
73 |
74 | // Returns the staticly generated post or the hydrated post from firebase
75 | const post = realtimePost || prefetchedPost;
76 |
77 | return (
78 | <>
79 |
80 |
81 | {post ? (
82 |
83 |
84 | {/* HEADER */}
85 |
86 |
87 |
88 |
89 | {post.title}
90 |
91 | {post.description && (
92 |
93 | {post.description}
94 |
95 | )}
96 |
97 |
98 |
110 |
111 |
112 | {/* Like and Comment buttons */}
113 |
116 |
117 | {/* Bookmark button */}
118 | {/* Share buttons */}
119 |
120 |
121 |
122 |
123 |
124 |
125 |
131 |
134 |
135 | ) : (
136 | Post not found
137 | )}
138 |
139 | >
140 | );
141 | };
142 |
143 | export default UserPost;
144 |
--------------------------------------------------------------------------------
/apps/bloggo/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { join } = require('path');
2 | const { createGlobPatternsForDependencies } = require('@nx/react/tailwind');
3 | const defaultTheme = require('tailwindcss/defaultTheme');
4 | const colors = require('tailwindcss/colors');
5 |
6 | // Helper function to grab theme colors from variables
7 | const customColors =
8 | (cssVar) =>
9 | ({ opacityVariable, opacityValue }) => {
10 | if (opacityValue !== undefined) {
11 | return `rgba(var(${cssVar}), ${opacityValue})`;
12 | }
13 | if (opacityVariable !== undefined) {
14 | return `rgba(var(${cssVar}), var(${opacityVariable}, 1))`;
15 | }
16 | return `rgb(var(${cssVar}))`;
17 | };
18 |
19 | module.exports = {
20 | // mode: 'jit',
21 | content: [
22 | join(__dirname, 'pages/**/*.{js,ts,jsx,tsx}'),
23 | ...createGlobPatternsForDependencies(__dirname),
24 | ],
25 | presets: [require('../../tailwind-workspace-preset.js')],
26 | darkMode: 'class', // or 'media' or 'class',
27 | theme: {
28 | container: {
29 | center: true,
30 | padding: {
31 | DEFAULT: '1rem',
32 | '2xl': '128px',
33 | },
34 | },
35 | fontFamily: {
36 | display: ['var(--font-display)', ...defaultTheme.fontFamily.sans],
37 | body: ['var(--font-body)', ...defaultTheme.fontFamily.sans],
38 | },
39 | colors: {
40 | transparent: 'transparent',
41 | current: 'currentColor',
42 | ...colors,
43 | primary: {
44 | 50: customColors('--c-primary-50'),
45 | 100: customColors('--c-primary-100'),
46 | 200: customColors('--c-primary-200'),
47 | 300: customColors('--c-primary-300'),
48 | 400: customColors('--c-primary-400'),
49 | 500: customColors('--c-primary-500'),
50 | 6000: customColors('--c-primary-600'),
51 | 700: customColors('--c-primary-700'),
52 | 800: customColors('--c-primary-800'),
53 | 900: customColors('--c-primary-900'),
54 | },
55 | secondary: {
56 | 50: customColors('--c-secondary-50'),
57 | 100: customColors('--c-secondary-100'),
58 | 200: customColors('--c-secondary-200'),
59 | 300: customColors('--c-secondary-300'),
60 | 400: customColors('--c-secondary-400'),
61 | 500: customColors('--c-secondary-500'),
62 | 6000: customColors('--c-secondary-600'),
63 | 700: customColors('--c-secondary-700'),
64 | 800: customColors('--c-secondary-800'),
65 | 900: customColors('--c-secondary-900'),
66 | },
67 | neutral: {
68 | 50: customColors('--c-neutral-50'),
69 | 100: customColors('--c-neutral-100'),
70 | 200: customColors('--c-neutral-200'),
71 | 300: customColors('--c-neutral-300'),
72 | 400: customColors('--c-neutral-400'),
73 | 500: customColors('--c-neutral-500'),
74 | 6000: customColors('--c-neutral-600'),
75 | 700: customColors('--c-neutral-700'),
76 | 800: customColors('--c-neutral-800'),
77 | 900: customColors('--c-neutral-900'),
78 | },
79 | },
80 | extend: {
81 | screens: {
82 | 'dark-mode': { raw: '(prefers-color-scheme: dark)' },
83 | },
84 | typography: (theme) => ({
85 | DEFAULT: {
86 | css: {
87 | color: theme('colors.neutral.700'),
88 | a: {
89 | color: theme('colors.primary.6000'),
90 | '&:hover': {
91 | color: theme('colors.primary.6000'),
92 | },
93 | },
94 | },
95 | },
96 | dark: {
97 | css: {
98 | color: theme('colors.neutral.300'),
99 | a: {
100 | color: theme('colors.primary.500'),
101 | '&:hover': {
102 | color: theme('colors.primary.500'),
103 | },
104 | },
105 |
106 | h1: {
107 | color: theme('colors.neutral.200'),
108 | },
109 | h2: {
110 | color: theme('colors.neutral.200'),
111 | },
112 | h3: {
113 | color: theme('colors.neutral.200'),
114 | },
115 | h4: {
116 | color: theme('colors.neutral.200'),
117 | },
118 | h5: {
119 | color: theme('colors.neutral.300'),
120 | },
121 | h6: {
122 | color: theme('colors.neutral.300'),
123 | },
124 | strong: {
125 | color: theme('colors.neutral.300'),
126 | },
127 | code: {
128 | color: theme('colors.neutral.300'),
129 | },
130 | blockquote: {
131 | color: theme('colors.neutral.200'),
132 | },
133 | figcaption: {
134 | color: theme('colors.neutral.400'),
135 | },
136 | },
137 | },
138 | }),
139 | },
140 | },
141 | variants: {
142 | extend: {
143 | animation: {
144 | 'spin-slow': 'spin 3s linear infinite',
145 | },
146 | },
147 | },
148 | };
149 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/onboarding.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { useFormik } from 'formik';
3 | import { debounce } from 'lodash';
4 | import { useRouter } from 'next/router';
5 | import React, { useCallback, useEffect, useState } from 'react';
6 | import * as Yup from 'yup';
7 |
8 | import {
9 | AppState,
10 | checkUsername,
11 | createUsernameWithUserData,
12 | useAppSelector,
13 | } from '@bloggo/redux';
14 | import {
15 | AppLayout,
16 | Button,
17 | FormFeedback,
18 | Input,
19 | Metatags,
20 | Toast,
21 | ToastProps,
22 | } from '@bloggo/ui';
23 |
24 | const UsernameSchema = Yup.object().shape({
25 | username: Yup.string()
26 | .min(3, 'Username is too short.')
27 | .required('Username is required.'),
28 | });
29 |
30 | const EnterPage: React.FC = () => {
31 | const router = useRouter();
32 | const { user, username } = useAppSelector((state: AppState) => state.user);
33 | const [toast, setToast] = useState({
34 | isOpen: false,
35 | // eslint-disable-next-line @typescript-eslint/no-empty-function
36 | toggle: () => {},
37 | text: {
38 | heading: '',
39 | body: '',
40 | },
41 | });
42 | const {
43 | handleChange,
44 | handleBlur,
45 | handleSubmit,
46 | setFieldError,
47 | errors,
48 | touched,
49 | values,
50 | } = useFormik({
51 | validationSchema: UsernameSchema,
52 | initialValues: { username: '' },
53 | onSubmit: async ({ username }) => {
54 | try {
55 | createUsernameWithUserData({
56 | uid: user.uid,
57 | username,
58 | displayName: user.displayName,
59 | photoURL: user.photoURL,
60 | });
61 | setToast({
62 | isOpen: true,
63 | toggle: () =>
64 | setToast((prevToast) => ({ ...prevToast, isOpen: false })),
65 | text: {
66 | heading: 'Login completed',
67 | body: 'You have successfully logged in. Welcome back!',
68 | },
69 | type: 'success',
70 | });
71 | } catch (error) {
72 | setToast({
73 | isOpen: true,
74 | toggle: () =>
75 | setToast((prevToast) => ({ ...prevToast, isOpen: false })),
76 | text: {
77 | heading: 'Login failed',
78 | body: 'Please check your credentials and try again.',
79 | },
80 | type: 'error',
81 | });
82 | }
83 |
84 | setTimeout(() => {
85 | setToast((prevToast) => ({ ...prevToast, isOpen: false }));
86 | }, 2000);
87 | },
88 | });
89 | // redirect to home page if user is logged in
90 | useEffect(() => {
91 | if (user && username) {
92 | router.replace('/');
93 | }
94 | }, [user, username]);
95 |
96 | const lookupUsername = useCallback(
97 | debounce(async (username) => {
98 | if (username.length >= 3) {
99 | const exists = await checkUsername(username);
100 | console.log('Firestore read executed!', exists);
101 | exists && setFieldError('username', 'That username has been taken.');
102 | }
103 | }, 500),
104 | [],
105 | );
106 | return (
107 | <>
108 |
109 |
114 |
115 |
154 |
155 |
156 |
157 |
158 | >
159 | );
160 | };
161 |
162 | export default EnterPage;
163 |
--------------------------------------------------------------------------------
/libs/ui/src/lib/header.component.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import React, { useEffect, useRef, useState } from 'react';
3 |
4 | import { Avatar } from './avatar.component';
5 | import { BookmarkPostButton, LikePostButton } from './buttons';
6 | import { SharePostButtonContainer } from './buttons/share-post-button-container-component';
7 | import { Navbar } from './navbar/';
8 |
9 | export const Header: React.FC = () => {
10 | const router = useRouter();
11 | const containerDivElRef = useRef(null);
12 | const mainMenuDivElRef = useRef(null);
13 | const progressBarDivElRef = useRef(null);
14 | const [previousScrollPosition, setPreviousScrollPosition] =
15 | useState(0);
16 |
17 | // TODO: fix showSingleMenu check
18 | const showSingleMenu = router.asPath === '/:username/:slug';
19 | const [isSingleHeaderShowing, setIsSingleHeaderShowing] = useState(false);
20 | const [isTopOfPage, setIsTopOfPage] = useState(true);
21 |
22 | useEffect(() => {
23 | if (window.scrollY !== previousScrollPosition) {
24 | setPreviousScrollPosition(window.scrollY);
25 | }
26 | }, [previousScrollPosition]);
27 |
28 | useEffect(() => {
29 | window.onscroll = () => {
30 | if (mainMenuDivElRef.current) {
31 | showHideHeaderMenu(mainMenuDivElRef.current.offsetHeight);
32 | }
33 | };
34 | }, []);
35 |
36 | useEffect(() => {
37 | if (showSingleMenu) {
38 | setTimeout(() => {
39 | // BECAUSE DIV HAVE TRANSITION 100ms
40 | window.addEventListener('scroll', showHideSingleHeader);
41 | }, 200);
42 | } else {
43 | window.removeEventListener('scroll', showHideSingleHeader);
44 | }
45 | }, [showSingleMenu]);
46 |
47 | const showHideHeaderMenu = (mainMenuHeight: number) => {
48 | const currentScrollPosition: number = window.pageYOffset;
49 | if (containerDivElRef.current && mainMenuDivElRef.current) {
50 | // SET BG
51 | if (previousScrollPosition < currentScrollPosition) {
52 | currentScrollPosition > mainMenuHeight
53 | ? setIsTopOfPage(false)
54 | : setIsTopOfPage(true);
55 | } else {
56 | currentScrollPosition > 0
57 | ? setIsTopOfPage(false)
58 | : setIsTopOfPage(true);
59 | }
60 |
61 | // SHOW _ HIDE MAIN MENU
62 | if (previousScrollPosition > currentScrollPosition) {
63 | containerDivElRef.current.style.top = '0';
64 | } else {
65 | containerDivElRef.current.style.top = `-${mainMenuHeight + 2}px`;
66 | }
67 | setPreviousScrollPosition(currentScrollPosition);
68 | }
69 | };
70 |
71 | const showHideSingleHeader = () => {
72 | handleProgressIndicator();
73 | // SHOW _ HIDE SINGLE DESC MENU
74 | const winScroll =
75 | document.body.scrollTop || document.documentElement.scrollTop;
76 |
77 | if (winScroll > 600) {
78 | setIsSingleHeaderShowing(true);
79 | } else {
80 | setIsSingleHeaderShowing(false);
81 | }
82 | };
83 | const handleProgressIndicator = () => {
84 | const entryContent = document.querySelector(
85 | '#single-entry-content',
86 | ) as HTMLDivElement | null;
87 |
88 | if (!showSingleMenu || !entryContent) {
89 | return;
90 | }
91 |
92 | const totalEntryH = entryContent.offsetTop + entryContent.offsetHeight;
93 | const winScroll =
94 | document.body.scrollTop || document.documentElement.scrollTop;
95 | let scrolled = (winScroll / totalEntryH) * 100;
96 | if (!progressBarDivElRef.current || scrolled > 140) {
97 | return;
98 | }
99 |
100 | scrolled = scrolled > 100 ? 100 : scrolled;
101 |
102 | progressBarDivElRef.current.style.width = scrolled + '%';
103 | };
104 | const renderSingleHeader = () => {
105 | if (!isSingleHeaderShowing) return null;
106 |
107 | return (
108 |
109 |
110 |
111 |
112 | {/*
*/}
118 |
119 | Placeholder title
120 |
121 |
122 |
123 |
124 | {/*
125 |
*/}
129 |
130 |
134 |
135 |
136 |
137 |
143 |
144 | );
145 | };
146 | return (
147 |
151 |
152 |
153 |
154 | {showSingleMenu && renderSingleHeader()}
155 |
156 | );
157 | };
158 |
--------------------------------------------------------------------------------
/apps/bloggo/pages/portal/create-post.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { serverTimestamp } from 'firebase/firestore';
3 | import { useFormik } from 'formik';
4 | import { kebabCase } from 'lodash';
5 | import { useRouter } from 'next/router';
6 | import React, { useState } from 'react';
7 | import ReactTooltip from 'react-tooltip';
8 | import * as Yup from 'yup';
9 |
10 | import {
11 | AppState,
12 | IFirestorePostPayload,
13 | createPost,
14 | useAppSelector,
15 | } from '@bloggo/redux';
16 | import {
17 | AppLayout,
18 | AuthCheck,
19 | Button,
20 | FormFeedback,
21 | Input,
22 | Label,
23 | Metatags,
24 | PortalNavbar,
25 | Toast,
26 | ToastProps,
27 | } from '@bloggo/ui';
28 |
29 | const PostSlugSchema = Yup.object().shape({
30 | title: Yup.string()
31 | .min(3, 'Title is too short.')
32 | .max(50, 'Title is too long.')
33 | .required('Title is required.'),
34 | });
35 |
36 | const CreatePostPage: React.FC = () => {
37 | const router = useRouter();
38 | const { user, username } = useAppSelector((state: AppState) => state.user);
39 | const [toast, setToast] = useState({
40 | isOpen: false,
41 | // eslint-disable-next-line @typescript-eslint/no-empty-function
42 | toggle: () => {},
43 | text: {
44 | heading: '',
45 | body: '',
46 | },
47 | });
48 | const { handleChange, handleBlur, handleSubmit, errors, touched, values } =
49 | useFormik({
50 | validationSchema: PostSlugSchema,
51 | initialValues: { title: '' },
52 | onSubmit: async ({ title }) => {
53 | try {
54 | const slug = encodeURI(kebabCase(title));
55 | if (user?.uid) {
56 | const postPayload: IFirestorePostPayload = {
57 | title,
58 | slug,
59 | content:
60 | '# Welcome to my blog!\n\nThis is a sample post. Feel free to edit it and delete it.\n\n---\n\n',
61 | description: 'Welcome to my blog!',
62 | createdAt: serverTimestamp(),
63 | updatedAt: serverTimestamp(),
64 | href: `${username}/${slug}`,
65 | likeCount: 0,
66 | published: false,
67 | thumbnail:
68 | 'https://images.pexels.com/photos/1591056/pexels-photo-1591056.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500',
69 | uid: user.uid,
70 | username,
71 | };
72 | createPost(user.uid, slug, postPayload);
73 | setToast({
74 | isOpen: true,
75 | toggle: () =>
76 | setToast((prevToast) => ({ ...prevToast, isOpen: false })),
77 | text: {
78 | heading: 'Post created!',
79 | body: 'Post has been created. Proceed to edit it.',
80 | },
81 | type: 'success',
82 | });
83 | setTimeout(() => {
84 | router.push(`/portal/${slug}`);
85 | }, 500);
86 | }
87 | } catch (error) {
88 | console.error(error);
89 | setToast({
90 | isOpen: true,
91 | toggle: () =>
92 | setToast((prevToast) => ({ ...prevToast, isOpen: false })),
93 | text: {
94 | heading: 'Login failed',
95 | body: 'Please check your credentials and try again.',
96 | },
97 | type: 'error',
98 | });
99 | setTimeout(() => {
100 | setToast((prevToast) => ({ ...prevToast, isOpen: false }));
101 | }, 2000);
102 | }
103 | },
104 | });
105 | return (
106 | <>
107 |
108 |
113 |
114 |
115 | {/* NAVBAR */}
116 |
117 |
118 |
119 |
160 |
161 |
162 |
163 |
164 |
165 |
166 | >
167 | );
168 | };
169 |
170 | export default CreatePostPage;
171 |
--------------------------------------------------------------------------------
/libs/redux/src/lib/firebase/firestore-queries.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DocumentData,
3 | DocumentReference,
4 | Timestamp,
5 | collection,
6 | collectionGroup,
7 | doc,
8 | getDoc,
9 | getDocs,
10 | limit,
11 | orderBy,
12 | query,
13 | setDoc,
14 | startAfter,
15 | where,
16 | writeBatch,
17 | } from 'firebase/firestore';
18 |
19 | import { IFirestorePostData, IFirestorePostPayload } from '.';
20 | import { db } from './firebase';
21 | import { docToJSON } from './helpers';
22 |
23 | /**`
24 | * Checks if a username doc exists in the database.
25 | * @param {string} username
26 | */
27 | export const checkUsername = async (username: string) => {
28 | const usernameDoc = await getDoc(doc(db, 'users', username));
29 | return usernameDoc.exists();
30 | };
31 |
32 | /**`
33 | * Batch creates the username and the user entries in the database.
34 | * @param {Record} user_payload
35 | */
36 | export const createUsernameWithUserData = async ({
37 | uid,
38 | username,
39 | displayName,
40 | photoURL,
41 | }: {
42 | uid: string;
43 | username: string;
44 | photoURL?: string;
45 | displayName?: string;
46 | }) => {
47 | // displayName can be the users name when they sign up with Google. Or it can be their username.
48 | const batch = writeBatch(db);
49 | batch.set(doc(db, 'users', uid), {
50 | username,
51 | photoURL: photoURL,
52 | displayName: displayName || username,
53 | createdAt: Timestamp.now(),
54 | });
55 | batch.set(doc(db, 'usernames', username), { uid });
56 | return await batch.commit();
57 | };
58 |
59 | /**`
60 | * Gets a users/{uid} document with username
61 | * @param {string} username
62 | */
63 | export const getUserDataFromUsername = async (username: string | string[]) => {
64 | const usersRef = collection(db, 'users');
65 |
66 | const q = query(usersRef, where('username', '==', username), limit(1));
67 | const querySnapshot = await getDocs(q);
68 | const [userDoc] = querySnapshot.docs;
69 | return userDoc;
70 | };
71 |
72 | /**`
73 | * Get the last postLimit posts by the user given the user document reference.
74 | * @param {DocumentReference} userDocRef
75 | * @param {number} postLimit
76 | */
77 | export const getUserPostsWithLimit = async (
78 | userDocRef: DocumentReference,
79 | postLimit: number,
80 | ) => {
81 | const postsRef = collection(userDocRef, 'posts');
82 | const q = query(
83 | postsRef,
84 | where('published', '==', true),
85 | orderBy('createdAt', 'desc'),
86 | limit(postLimit),
87 | );
88 | const querySnapshot = await getDocs(q);
89 | return querySnapshot.docs.map(docToJSON);
90 | };
91 |
92 | /**`
93 | * Get the last postLimit posts ordered by createdAt date.
94 | * @param {number} postLimit
95 | */
96 | export const getPostsWithLimit = async (postLimit: number) => {
97 | const ref = collectionGroup(db, 'posts');
98 | const q = query(
99 | ref,
100 | where('published', '==', true),
101 | orderBy('createdAt', 'desc'),
102 | limit(postLimit),
103 | );
104 | const querySnapshot = await getDocs(q);
105 | return querySnapshot.docs.map(docToJSON);
106 | };
107 |
108 | /**`
109 | * Get the last usernameLimit username ordered by createdAt date.
110 | * @param {number} usersLimit
111 | */
112 | export const getUsernamesOrderedByDateWithLimit = async (
113 | usernameLimit: number,
114 | ) => {
115 | const ref = collection(db, 'usernames');
116 | const q = query(ref, orderBy('createdAt', 'desc'), limit(usernameLimit));
117 | const querySnapshot = await getDocs(q);
118 | return querySnapshot.docs.map((doc) => {
119 | const data = doc.data();
120 | return {
121 | username: doc.id,
122 | createdAt: data.createdAt.toMillis(),
123 | };
124 | });
125 | };
126 |
127 | /**`
128 | * Get the last postLimit posts ordered by createdAt date.
129 | * @param {number} postLimit
130 | * @param {number} startingAt
131 | */
132 | export const getPostsWithLimitStartingAt = async (
133 | postLimit: number,
134 | startingAt: Timestamp,
135 | ) => {
136 | const ref = collectionGroup(db, 'posts');
137 | const q = query(
138 | ref,
139 | where('published', '==', true),
140 | orderBy('createdAt', 'desc'),
141 | startAfter(startingAt),
142 | limit(postLimit),
143 | );
144 | const querySnapshot = await getDocs(q);
145 | return querySnapshot.docs.map(docToJSON);
146 | };
147 |
148 | /**`
149 | * Get the last postLimit posts ordered by like count.
150 | * @param {number} postLimit
151 | */
152 | export const getPostsByLikesWithLimit = async (postLimit: number) => {
153 | const ref = collectionGroup(db, 'posts');
154 | const q = query(
155 | ref,
156 | where('published', '==', true),
157 | orderBy('likeCount', 'desc'),
158 | limit(postLimit),
159 | );
160 | const querySnapshot = await getDocs(q);
161 | return querySnapshot.docs.map(docToJSON);
162 | };
163 |
164 | // TODO: fix architecture so that the slug field and document id aren't bound together. A post document id should be unique and slugs should be its own collection that maps to it.
165 |
166 | /**`
167 | * Get a post by its document id (slug) for a specific user given the userPath.
168 | * @param {string} userPath
169 | * @param {string} slug
170 | */
171 | export const getPostBySlugForUser = async (
172 | userPath: string,
173 | slug: string | string[],
174 | ) => {
175 | const ref = doc(db, userPath, 'posts', Array.isArray(slug) ? slug[0] : slug);
176 | const docSnapshot = await getDoc(ref);
177 | console.log(docSnapshot.data());
178 | return [ref.path, docToJSON(docSnapshot)] as const;
179 | };
180 |
181 | // TODO: move this to the admin sdk for better performance
182 | /**`
183 | * Get posts
184 | */
185 | export const getPostPaths = async () => {
186 | const ref = collectionGroup(db, 'posts');
187 | const querySnapshot = await getDocs(ref);
188 | return querySnapshot.docs.map((doc) => {
189 | const { slug, username } = doc.data();
190 | return {
191 | params: { username, slug },
192 | };
193 | });
194 | };
195 |
196 | /**`
197 | * Create a post for a user given the userId and post id (slug).
198 | *
199 | * @param {string} userId
200 | * @param {string} slug
201 | * @param {IFirestorePostPayload} postPayload
202 | */
203 | export const createPost = async (
204 | userId: string,
205 | slug: string,
206 | postPayload: IFirestorePostPayload,
207 | ) => {
208 | const ref = doc(db, 'users', userId, 'posts', slug);
209 | return await setDoc(ref, postPayload);
210 | };
211 |
--------------------------------------------------------------------------------