├── nextbranches.md
├── app
├── favicon.ico
├── (auth)
│ ├── sign-in
│ │ └── page.tsx
│ ├── sign-up
│ │ └── page.tsx
│ └── layout.tsx
├── (root)
│ ├── posts
│ │ └── [id]
│ │ │ └── page.tsx
│ ├── profile
│ │ └── [id]
│ │ │ ├── page.tsx
│ │ │ ├── followers
│ │ │ └── page.tsx
│ │ │ └── following
│ │ │ └── page.tsx
│ ├── layout.tsx
│ ├── (home)
│ │ └── page.tsx
│ ├── create-post
│ │ └── page.tsx
│ ├── edit-profile
│ │ └── [id]
│ │ │ └── page.tsx
│ ├── edit-post
│ │ └── [id]
│ │ │ └── page.tsx
│ ├── community
│ │ └── page.tsx
│ ├── collection
│ │ └── page.tsx
│ └── explore
│ │ └── page.tsx
└── layout.tsx
├── postcss.config.js
├── public
├── assets
│ ├── icons
│ │ ├── magnify.png
│ │ ├── arrow.svg
│ │ ├── liked.svg
│ │ ├── jsm-arrow.svg
│ │ ├── search.svg
│ │ ├── search-colored.svg
│ │ ├── loader.svg
│ │ ├── add-square.svg
│ │ ├── saved.svg
│ │ ├── filter.svg
│ │ ├── profile-placeholder.svg
│ │ ├── posts.svg
│ │ ├── error.svg
│ │ ├── google.svg
│ │ ├── like.svg
│ │ ├── follow.svg
│ │ ├── chat.svg
│ │ ├── back.svg
│ │ ├── share.svg
│ │ ├── people.svg
│ │ ├── home.svg
│ │ ├── gallery-add.svg
│ │ ├── add-post.svg
│ │ ├── bookmark.svg
│ │ ├── save.svg
│ │ ├── delete.svg
│ │ ├── edit.svg
│ │ ├── file-upload.svg
│ │ ├── logout.svg
│ │ └── wallpaper.svg
│ └── images
│ │ ├── profile.png
│ │ ├── ladunjexa.jpeg
│ │ ├── site-logo.png
│ │ ├── logo-colored.png
│ │ ├── avatar-circle.svg
│ │ └── logo-black.svg
├── vercel.svg
└── next.svg
├── .eslintrc.json
├── components
├── shared
│ ├── atoms
│ │ ├── Loader.tsx
│ │ └── Alert.tsx
│ ├── layout
│ │ ├── Bottombar.tsx
│ │ ├── RightSidebar.tsx
│ │ ├── Topbar.tsx
│ │ └── LeftSidebar.tsx
│ ├── Story.tsx
│ ├── GridPostList.tsx
│ ├── search
│ │ ├── LocalSearchbar.tsx
│ │ └── LocalResult.tsx
│ ├── FileUploader.tsx
│ └── PostStats.tsx
├── scenes
│ ├── AllStories.tsx
│ ├── AllUsers.tsx
│ ├── SavedPosts.tsx
│ ├── RecentPosts.tsx
│ ├── Follows.tsx
│ ├── Post.tsx
│ └── Profile.tsx
├── ui
│ ├── label.tsx
│ ├── textarea.tsx
│ ├── input.tsx
│ ├── toaster.tsx
│ ├── button.tsx
│ ├── tabs.tsx
│ ├── dialog.tsx
│ ├── use-toast.ts
│ ├── form.tsx
│ └── toast.tsx
├── cards
│ ├── FollowCard.tsx
│ ├── PostCard.tsx
│ └── UserCard.tsx
└── forms
│ ├── Post.tsx
│ ├── Profile.tsx
│ └── Auth.tsx
├── .vscode
└── settings.json
├── components.json
├── .prettierrc
├── appwrite
├── conf
│ └── index.ts
├── client.ts
├── actions
│ ├── save.action.ts
│ ├── user.action.ts
│ └── post.action.ts
└── env.ts
├── next.config.js
├── .gitignore
├── lib
├── react-query
│ ├── Provider.tsx
│ ├── QueryKeys.ts
│ ├── queries
│ │ ├── user.query.ts
│ │ └── post.query.ts
│ └── mutations
│ │ ├── save.mutation.ts
│ │ ├── post.mutation.ts
│ │ └── user.mutation.ts
├── validations
│ └── index.ts
└── utils.ts
├── tsconfig.json
├── hooks
└── useDebounce.ts
├── LICENSE
├── constants
└── index.ts
├── types
└── index.d.ts
├── package.json
├── tailwind.config.ts
└── context
└── AuthContext.tsx
/nextbranches.md:
--------------------------------------------------------------------------------
1 | 028_loading 029_toast_notifications 030_seo_metadata 031_playground
2 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs14-snapshot/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/assets/icons/magnify.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs14-snapshot/HEAD/public/assets/icons/magnify.png
--------------------------------------------------------------------------------
/public/assets/images/profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs14-snapshot/HEAD/public/assets/images/profile.png
--------------------------------------------------------------------------------
/public/assets/images/ladunjexa.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs14-snapshot/HEAD/public/assets/images/ladunjexa.jpeg
--------------------------------------------------------------------------------
/public/assets/images/site-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs14-snapshot/HEAD/public/assets/images/site-logo.png
--------------------------------------------------------------------------------
/public/assets/images/logo-colored.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs14-snapshot/HEAD/public/assets/images/logo-colored.png
--------------------------------------------------------------------------------
/app/(auth)/sign-in/page.tsx:
--------------------------------------------------------------------------------
1 | import Auth from '@/components/forms/Auth';
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/(auth)/sign-up/page.tsx:
--------------------------------------------------------------------------------
1 | import Auth from '@/components/forms/Auth';
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "standard", "plugin:tailwindcss/recommended", "prettier"],
3 | "ignorePatterns": ["/components/ui/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/public/assets/icons/arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/shared/atoms/Loader.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | const Loader = ({otherClasses}: {otherClasses?: string}) => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
11 | export default Loader;
12 |
--------------------------------------------------------------------------------
/app/(root)/posts/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import Post from '@/components/scenes/Post';
2 |
3 | import type {Metadata} from 'next';
4 |
5 | export const metadata: Metadata = {
6 | title: 'Posts — SnapShot',
7 | };
8 |
9 | type Props = {
10 | params: {id: string};
11 | };
12 |
13 | export default function Page({params}: Props) {
14 | return ;
15 | }
16 |
--------------------------------------------------------------------------------
/app/(root)/profile/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import Profile from '@/components/scenes/Profile';
2 |
3 | import type {Metadata} from 'next';
4 |
5 | export const metadata: Metadata = {
6 | title: 'Profile — SnapShot',
7 | };
8 |
9 | type Props = {
10 | params: {id: string};
11 | };
12 |
13 | export default function Page({params}: Props) {
14 | return ;
15 | }
16 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "editor.codeActionsOnSave": {
5 | "source.fixAll.eslint": true,
6 | "source.addMissingImports": true
7 | },
8 | "[typescriptreact]": {
9 | "editor.defaultFormatter": "esbenp.prettier-vscode"
10 | },
11 | "svg.preview.background": "dark-transparent"
12 | }
13 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json.schemastore.org/prettierrc",
3 | "arrowParens": "avoid",
4 | "bracketSpacing": false,
5 | "jsxBracketSameLine": false,
6 | "jsxSingleQuote": false,
7 | "printWidth": 100,
8 | "proseWrap": "always",
9 | "quoteProps": "as-needed",
10 | "semi": true,
11 | "singleQuote": true,
12 | "tabWidth": 2,
13 | "trailingComma": "es5",
14 | "useTabs": false
15 | }
16 |
--------------------------------------------------------------------------------
/app/(root)/profile/[id]/followers/page.tsx:
--------------------------------------------------------------------------------
1 | import Follows from '@/components/scenes/Follows';
2 |
3 | import type {Metadata} from 'next';
4 |
5 | export const metadata: Metadata = {
6 | title: 'Followers — SnapShot',
7 | };
8 |
9 | type Props = {
10 | params: {id: string};
11 | };
12 |
13 | export default function Page({params}: Props) {
14 | return ;
15 | }
16 |
--------------------------------------------------------------------------------
/app/(root)/profile/[id]/following/page.tsx:
--------------------------------------------------------------------------------
1 | import Follows from '@/components/scenes/Follows';
2 |
3 | import type {Metadata} from 'next';
4 |
5 | export const metadata: Metadata = {
6 | title: 'Following — SnapShot',
7 | };
8 |
9 | type Props = {
10 | params: {id: string};
11 | };
12 |
13 | export default function Page({params}: Props) {
14 | return ;
15 | }
16 |
--------------------------------------------------------------------------------
/appwrite/conf/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | url,
3 | projectId,
4 | databaseId,
5 | storageId,
6 | userCollectionId,
7 | postCollectionId,
8 | saveCollectionId,
9 | } from '@/appwrite/env';
10 |
11 | const appwriteConfig = {
12 | url,
13 | projectId,
14 | databaseId,
15 | storageId,
16 | userCollectionId,
17 | postCollectionId,
18 | saveCollectionId,
19 | };
20 |
21 | export default appwriteConfig;
22 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: 'https',
7 | hostname: 'cloud.appwrite.io',
8 | },
9 | {
10 | protocol: 'http',
11 | hostname: 'cloud.appwrite.io',
12 | },
13 | ],
14 | unoptimized: true,
15 | },
16 | };
17 |
18 | module.exports = nextConfig;
19 |
--------------------------------------------------------------------------------
/public/assets/icons/liked.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/appwrite/client.ts:
--------------------------------------------------------------------------------
1 | import {Client, Account, Databases, Storage, Avatars} from 'appwrite';
2 |
3 | import appwriteConfig from '@/appwrite/conf';
4 |
5 | export const client = new Client();
6 |
7 | client.setEndpoint(appwriteConfig.url).setProject(appwriteConfig.projectId);
8 |
9 | export const account = new Account(client);
10 | export const database = new Databases(client);
11 | export const storage = new Storage(client);
12 | export const avatars = new Avatars(client);
13 | export {ID} from 'appwrite';
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/public/assets/icons/jsm-arrow.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/assets/images/avatar-circle.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/react-query/Provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, {useState} from 'react';
4 |
5 | import {QueryClientProvider, QueryClient} from '@tanstack/react-query';
6 | import {ReactQueryDevtools} from '@tanstack/react-query-devtools';
7 |
8 | const showDevtools: boolean = true;
9 |
10 | export default function Provider({children}: {children: React.ReactNode}) {
11 | const [queryClient] = useState(() => new QueryClient());
12 |
13 | return (
14 |
15 | {showDevtools && }
16 | {children}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/app/(root)/layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Bottombar from '@/components/shared/layout/Bottombar';
4 | import LeftSidebar from '@/components/shared/layout/LeftSidebar';
5 | import RightSidebar from '@/components/shared/layout/RightSidebar';
6 | import Topbar from '@/components/shared/layout/Topbar';
7 |
8 | export default function Layout({children}: {children: React.ReactNode}) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/lib/react-query/QueryKeys.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 |
3 | enum QUERY_KEYS {
4 | // AUTH KEYS
5 | CREATE_USER_ACCOUNT = 'createUserAccount',
6 |
7 | // USER KEYS
8 | GET_CURRENT_USER = 'getCurrentUser',
9 | GET_USERS = 'getUsers',
10 | GET_USER_BY_ID = 'getUserById',
11 |
12 | // POST KEYS
13 | GET_POSTS = 'getPosts',
14 | GET_INFINITE_POSTS = 'getInfinitePosts',
15 | GET_RECENT_POSTS = 'getRecentPosts',
16 | GET_POST_BY_ID = 'getPostById',
17 | GET_USER_POSTS = 'getUserPosts',
18 | GET_FILE_PREVIEW = 'getFilePreview',
19 |
20 | // SEARCH KEYS
21 | SEARCH_POSTS = 'getSearchPosts',
22 | }
23 |
24 | export default QUERY_KEYS;
25 |
--------------------------------------------------------------------------------
/app/(root)/(home)/page.tsx:
--------------------------------------------------------------------------------
1 | // import AllStories from '@/components/scenes/AllStories';
2 | import RecentPosts from '@/components/scenes/RecentPosts';
3 |
4 | import type {Metadata} from 'next';
5 |
6 | export const metadata: Metadata = {
7 | title: 'Home — SnapShot',
8 | };
9 |
10 | export default function Home() {
11 | return (
12 |
13 |
14 |
15 | {/*
*/}
16 |
17 |
Feed
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Image from 'next/image';
3 |
4 | import type {Metadata} from 'next';
5 |
6 | export const metadata: Metadata = {
7 | title: 'Auth — SnapShot',
8 | };
9 |
10 | export default function Layout({children}: {children: React.ReactNode}) {
11 | return (
12 | <>
13 | {children}
14 |
15 |
22 | >
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/public/assets/icons/search.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/search-colored.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/loader.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/components/scenes/AllStories.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from 'next/image';
4 |
5 | import Story from '@/components/shared/Story';
6 |
7 | const AllStories = () => {
8 | return (
9 |
10 |
11 | {...Array(5).fill(
)}
12 |
13 |
19 |
20 |
21 | );
22 | };
23 |
24 | export default AllStories;
25 |
--------------------------------------------------------------------------------
/app/(root)/create-post/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | import Post from '@/components/forms/Post';
4 |
5 | import type {Metadata} from 'next';
6 |
7 | export const metadata: Metadata = {
8 | title: 'Create Post — SnapShot',
9 | };
10 |
11 | export default function Page() {
12 | return (
13 |
14 |
15 |
16 |
17 |
Create Post
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/app/(root)/edit-profile/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | import Profile from '@/components/forms/Profile';
4 |
5 | import type {Metadata} from 'next';
6 |
7 | export const metadata: Metadata = {
8 | title: 'Edit Post — SnapShot',
9 | };
10 |
11 | type Props = {
12 | params: {id: string};
13 | };
14 |
15 | export default function Page({params}: Props) {
16 | return (
17 |
18 |
19 |
20 |
21 |
Edit Profile
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/app/(root)/edit-post/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | import Post from '@/components/forms/Post';
4 |
5 | import type {Metadata} from 'next';
6 |
7 | export const metadata: Metadata = {
8 | title: 'Edit Post — SnapShot',
9 | };
10 |
11 | type Props = {
12 | params: {id: string};
13 | };
14 |
15 | export default function Page({params}: Props) {
16 | return (
17 |
18 |
19 |
20 |
21 |
Edit Post
22 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/app/(root)/community/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | import AllUsers from '@/components/scenes/AllUsers';
4 |
5 | import type {Metadata} from 'next';
6 |
7 | export const metadata: Metadata = {
8 | title: 'Community — SnapShot',
9 | };
10 |
11 | export default function Page() {
12 | return (
13 |
14 |
15 |
16 |
23 |
All Users
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/app/(root)/collection/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | import SavedPosts from '@/components/scenes/SavedPosts';
4 |
5 | import type {Metadata} from 'next';
6 |
7 | export const metadata: Metadata = {
8 | title: 'Collection — SnapShot',
9 | };
10 |
11 | export default function Page() {
12 | return (
13 |
14 |
15 |
16 |
23 |
Saved Posts
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 |
3 | // https://codesandbox.io/s/react-query-debounce-ted8o?file=/src/useDebounce.js
4 | export default function useDebounce(value: T, delay: number): T {
5 | // State and setters for debounced value
6 | const [debouncedValue, setDebouncedValue] = useState(value);
7 |
8 | useEffect(() => {
9 | // Update debounced value after delay
10 | const handler = setTimeout(() => {
11 | setDebouncedValue(value);
12 | }, delay);
13 |
14 | // Cancel the timeout if value changes (also on delay change or unmount)
15 | // This is how we prevent debounced value from updating if value is changed ...
16 | // .. within the delay period. Timeout gets cleared and restarted.
17 | return () => {
18 | clearTimeout(handler);
19 | };
20 | }, [value, delay]); // Only re-call effect if value or delay changes
21 |
22 | return debouncedValue;
23 | }
24 |
--------------------------------------------------------------------------------
/public/assets/icons/add-square.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from "@/components/ui/toast"
11 | import { useToast } from "@/components/ui/use-toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/public/assets/icons/saved.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/filter.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/components/scenes/AllUsers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import UserCard from '@/components/cards/UserCard';
4 | import Loader from '@/components/shared/atoms/Loader';
5 | import Alert from '@/components/shared/atoms/Alert';
6 |
7 | import {useGetUsers} from '@/lib/react-query/queries/user.query';
8 |
9 | import {ERROR_ALERT_PROPS} from '@/constants';
10 |
11 | const AllUsers = () => {
12 | const {data: creators, isLoading: isUserLoading, isError: isCreatorsError} = useGetUsers(10);
13 |
14 | if (isCreatorsError) return ;
15 |
16 | return (
17 | <>
18 | {isUserLoading && !creators ? (
19 |
20 | ) : (
21 |
22 | {creators?.documents.map(creator => (
23 | -
24 |
25 |
26 | ))}
27 |
28 | )}
29 | >
30 | );
31 | };
32 |
33 | export default AllUsers;
34 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Inter} from 'next/font/google';
3 | import './globals.css';
4 |
5 | import AuthProvider from '@/context/AuthContext';
6 |
7 | import {Toaster} from '@/components/ui/toaster';
8 |
9 | import Provider from '@/lib/react-query/Provider';
10 |
11 | import type {Metadata} from 'next';
12 |
13 | const inter = Inter({subsets: ['latin']});
14 |
15 | export const metadata: Metadata = {
16 | title: 'SnapShot',
17 | description: 'SnapShot is a social media app for sharing photos',
18 | icons: {
19 | icon: '/assets/images/site-logo.png',
20 | },
21 | };
22 |
23 | export default function RootLayout({children}: {children: React.ReactNode}) {
24 | return (
25 |
26 |
27 |
28 |
29 | {children}
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/shared/atoms/Alert.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 |
4 | import {Button} from '@/components/ui/button';
5 |
6 | interface Props {
7 | title: string;
8 | description: string;
9 | link: string;
10 | linkTitle: string;
11 | imgSrc: string;
12 | }
13 |
14 | const Alert = ({title, description, link, linkTitle, imgSrc}: Props) => {
15 | return (
16 |
17 |
18 |
19 |
{title}
20 |
21 | {description}
22 |
23 |
24 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default Alert;
33 |
--------------------------------------------------------------------------------
/public/assets/icons/profile-placeholder.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/assets/icons/posts.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/appwrite/actions/save.action.ts:
--------------------------------------------------------------------------------
1 | import {database, ID} from '@/appwrite/client';
2 | import appwriteConfig from '@/appwrite/conf';
3 |
4 | export async function savePost(postId: string, userId: string) {
5 | try {
6 | const updatedPost = await database.createDocument(
7 | appwriteConfig.databaseId,
8 | appwriteConfig.saveCollectionId,
9 | ID.unique(),
10 | {
11 | user: userId,
12 | post: postId,
13 | }
14 | );
15 |
16 | if (!updatedPost) {
17 | throw new Error('Post update failed');
18 | }
19 |
20 | return updatedPost;
21 | } catch (error) {
22 | console.error(error);
23 | }
24 | }
25 |
26 | export async function deleteSavedPost(savedRecordId: string) {
27 | try {
28 | const deletedRecord = await database.deleteDocument(
29 | appwriteConfig.databaseId,
30 | appwriteConfig.saveCollectionId,
31 | savedRecordId
32 | );
33 |
34 | if (!deletedRecord) {
35 | throw new Error('Post deletion failed');
36 | }
37 |
38 | return deletedRecord;
39 | } catch (error) {
40 | console.error(error);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/lib/react-query/queries/user.query.ts:
--------------------------------------------------------------------------------
1 | import {useQuery} from '@tanstack/react-query';
2 |
3 | import {getCurrentUser, getUserById, getUsers} from '@/appwrite/actions/user.action';
4 | import {getUserPosts} from '@/appwrite/actions/post.action';
5 |
6 | import QUERY_KEYS from '@/lib/react-query/QueryKeys';
7 |
8 | export const useGetCurrentUser = () => {
9 | return useQuery({
10 | queryKey: [QUERY_KEYS.GET_CURRENT_USER],
11 | queryFn: getCurrentUser,
12 | });
13 | };
14 |
15 | export const useGetUsers = (limit?: number) => {
16 | return useQuery({
17 | queryKey: [QUERY_KEYS.GET_USERS],
18 | queryFn: () => getUsers(limit),
19 | });
20 | };
21 |
22 | export const useGetUserById = (userId: string) => {
23 | return useQuery({
24 | queryKey: [QUERY_KEYS.GET_USER_BY_ID, userId],
25 | queryFn: () => getUserById(userId),
26 | enabled: !!userId,
27 | });
28 | };
29 |
30 | export const useGetUserPosts = (userId?: string) => {
31 | return useQuery({
32 | queryKey: [QUERY_KEYS.GET_USER_POSTS, userId],
33 | queryFn: () => getUserPosts(userId),
34 | enabled: !!userId,
35 | });
36 | };
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Liron Abutbul
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 |
--------------------------------------------------------------------------------
/public/assets/icons/error.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/icons/google.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/components/shared/layout/Bottombar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import Image from 'next/image';
5 | import {usePathname} from 'next/navigation';
6 |
7 | import {bottombarLinks} from '@/constants';
8 |
9 | import type {INavLink} from '@/types';
10 |
11 | const Bottombar = () => {
12 | const pathname = usePathname();
13 |
14 | return (
15 |
16 | {bottombarLinks.map((link: INavLink) => {
17 | const isActive =
18 | (pathname.includes(link.route) && link.route.length > 1) || pathname === link.route;
19 |
20 | return (
21 |
28 |
35 | {link.label}
36 |
37 | );
38 | })}
39 |
40 | );
41 | };
42 |
43 | export default Bottombar;
44 |
--------------------------------------------------------------------------------
/appwrite/env.ts:
--------------------------------------------------------------------------------
1 | export const url: string = process.env.NEXT_PUBLIC_APPWRITE_URL || 'https://cloud.appwrite.io/v1';
2 |
3 | export const projectId: string = assertValue(
4 | process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID,
5 | 'NEXT_PUBLIC_APPWRITE_PROJECT_ID'
6 | );
7 |
8 | export const databaseId: string = assertValue(
9 | process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID,
10 | 'NEXT_PUBLIC_APPWRITE_DATABASE_ID'
11 | );
12 |
13 | export const storageId: string = assertValue(
14 | process.env.NEXT_PUBLIC_APPWRITE_STORAGE_ID,
15 | 'NEXT_PUBLIC_APPWRITE_STORAGE_ID'
16 | );
17 |
18 | export const userCollectionId: string = assertValue(
19 | process.env.NEXT_PUBLIC_APPWRITE_USER_COLLECTION_ID,
20 | 'NEXT_PUBLIC_APPWRITE_USER_COLLECTION_ID'
21 | );
22 |
23 | export const postCollectionId: string = assertValue(
24 | process.env.NEXT_PUBLIC_APPWRITE_POST_COLLECTION_ID,
25 | 'NEXT_PUBLIC_APPWRITE_POST_COLLECTION_ID'
26 | );
27 |
28 | export const saveCollectionId: string = assertValue(
29 | process.env.NEXT_PUBLIC_APPWRITE_SAVES_COLLECTION_ID,
30 | 'NEXT_PUBLIC_APPWRITE_SAVES_COLLECTION_ID'
31 | );
32 |
33 | function assertValue(v: T | undefined, envVarName: string): T {
34 | if (v === undefined) {
35 | throw new Error(`Missing environment variable: ${envVarName}`);
36 | }
37 |
38 | return v;
39 | }
40 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/(root)/explore/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | import LocalSearchbar from '@/components/shared/search/LocalSearchbar';
4 | import LocalResult from '@/components/shared/search/LocalResult';
5 |
6 | import type {Metadata} from 'next';
7 |
8 | export const metadata: Metadata = {
9 | title: 'Explore — SnapShot',
10 | };
11 |
12 | type Props = {
13 | searchParams: {[key: string]: string | undefined};
14 | };
15 |
16 | export default function Page({searchParams}: Props) {
17 | const query = searchParams.q;
18 |
19 | return (
20 |
21 |
22 |
Search Posts
23 |
24 |
25 |
26 |
27 |
28 |
29 | {!query ? 'Popular Today' : `Search results for "${query}"`}
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/lib/validations/index.ts:
--------------------------------------------------------------------------------
1 | import * as z from 'zod';
2 |
3 | export const SignUpValidation = z.object({
4 | name: z.string().min(2, {message: 'Name must be at least 2 characters long'}),
5 | username: z.string().min(2, {message: 'Username must be at least 2 characters long'}),
6 | email: z.string().email(),
7 | password: z.string().min(8, {message: 'Password must be at least 8 characters long'}),
8 | });
9 |
10 | export const SignInValidation = z.object({
11 | email: z.string().email(),
12 | password: z.string().min(8, {message: 'Password must be at least 8 characters long'}),
13 | });
14 |
15 | export const ProfileValidation = z.object({
16 | file: z.custom(),
17 | name: z.string().min(2, {message: 'Name must be at least 2 characters long'}),
18 | username: z.string().min(2, {message: 'Username must be at least 2 characters long'}),
19 | email: z.string().email(),
20 | bio: z.string(),
21 | });
22 |
23 | export const PostValidation = z.object({
24 | caption: z
25 | .string()
26 | .min(5, {
27 | message: 'Caption must be at least 5 characters.',
28 | })
29 | .max(2000, {message: 'Caption must be less than 2000 characters.'}),
30 | file: z.custom(),
31 | location: z
32 | .string()
33 | .min(2, {message: 'Location must be at least 2 character.'})
34 | .max(1000, {message: 'Location must be less than 1000 characters.'}),
35 | tags: z.string(),
36 | });
37 |
--------------------------------------------------------------------------------
/lib/react-query/mutations/save.mutation.ts:
--------------------------------------------------------------------------------
1 | import {useQueryClient, useMutation} from '@tanstack/react-query';
2 |
3 | import {savePost, deleteSavedPost} from '@/appwrite/actions/save.action';
4 |
5 | import QUERY_KEYS from '@/lib/react-query/QueryKeys';
6 |
7 | export const useSavePost = () => {
8 | const queryClient = useQueryClient();
9 |
10 | return useMutation({
11 | mutationFn: ({postId, userId}: {postId: string; userId: string}) => savePost(postId, userId),
12 | onSuccess: data => {
13 | queryClient.invalidateQueries({
14 | queryKey: [QUERY_KEYS.GET_RECENT_POSTS],
15 | });
16 | queryClient.invalidateQueries({
17 | queryKey: [QUERY_KEYS.GET_POSTS],
18 | });
19 | queryClient.invalidateQueries({
20 | queryKey: [QUERY_KEYS.GET_CURRENT_USER],
21 | });
22 | },
23 | });
24 | };
25 |
26 | export const useDeleteSavedPost = () => {
27 | const queryClient = useQueryClient();
28 |
29 | return useMutation({
30 | mutationFn: (savedRecordId: string) => deleteSavedPost(savedRecordId),
31 | onSuccess: data => {
32 | queryClient.invalidateQueries({
33 | queryKey: [QUERY_KEYS.GET_RECENT_POSTS],
34 | });
35 | queryClient.invalidateQueries({
36 | queryKey: [QUERY_KEYS.GET_POSTS],
37 | });
38 | queryClient.invalidateQueries({
39 | queryKey: [QUERY_KEYS.GET_CURRENT_USER],
40 | });
41 | },
42 | });
43 | };
44 |
--------------------------------------------------------------------------------
/components/scenes/SavedPosts.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import GridPostList from '@/components/shared/GridPostList';
4 | import Loader from '@/components/shared/atoms/Loader';
5 | import Alert from '@/components/shared/atoms/Alert';
6 |
7 | import {useGetCurrentUser} from '@/lib/react-query/queries/user.query';
8 |
9 | import type {Models} from 'appwrite';
10 |
11 | type Props = {};
12 |
13 | const SavedPosts = (props: Props) => {
14 | const {data: currentUser} = useGetCurrentUser();
15 |
16 | const savedPosts = currentUser?.save.map((savePost: Models.Document) => ({
17 | ...savePost.post,
18 | creator: {
19 | imageUrl: currentUser.imageUrl,
20 | },
21 | }));
22 |
23 | return (
24 | <>
25 | {!currentUser ? (
26 |
27 | ) : (
28 |
29 | {savedPosts.length === 0 ? (
30 |
37 | ) : (
38 |
39 | )}
40 |
41 | )}
42 | >
43 | );
44 | };
45 |
46 | export default SavedPosts;
47 |
--------------------------------------------------------------------------------
/constants/index.ts:
--------------------------------------------------------------------------------
1 | import type {INavLink} from '@/types';
2 |
3 | export const sidebarLinks: INavLink[] = [
4 | {
5 | imgURL: '/assets/icons/home.svg',
6 | route: '/',
7 | label: 'Home',
8 | },
9 | {
10 | imgURL: '/assets/icons/wallpaper.svg',
11 | route: '/explore',
12 | label: 'Explore',
13 | },
14 | {
15 | imgURL: '/assets/icons/people.svg',
16 | route: '/community',
17 | label: 'Community',
18 | },
19 | {
20 | imgURL: '/assets/icons/bookmark.svg',
21 | route: '/collection',
22 | label: 'Collection',
23 | },
24 | {
25 | imgURL: '/assets/icons/gallery-add.svg',
26 | route: '/create-post',
27 | label: 'Create Post',
28 | },
29 | ];
30 |
31 | export const bottombarLinks: INavLink[] = [
32 | {
33 | imgURL: '/assets/icons/home.svg',
34 | route: '/',
35 | label: 'Home',
36 | },
37 | {
38 | imgURL: '/assets/icons/wallpaper.svg',
39 | route: '/explore',
40 | label: 'Explore',
41 | },
42 | {
43 | imgURL: '/assets/icons/bookmark.svg',
44 | route: '/collection',
45 | label: 'Collection',
46 | },
47 | {
48 | imgURL: '/assets/icons/gallery-add.svg',
49 | route: '/create-post',
50 | label: 'Create',
51 | },
52 | ];
53 |
54 | export const ERROR_ALERT_PROPS = {
55 | title: 'Error Occured',
56 | description: 'Something went wrong. Please try again later.',
57 | link: '/',
58 | linkTitle: 'Explore Posts',
59 | imgSrc: '/assets/icons/error.svg',
60 | };
61 |
--------------------------------------------------------------------------------
/components/shared/layout/RightSidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {usePathname} from 'next/navigation';
4 |
5 | import UserCard from '@/components/cards/UserCard';
6 | import Loader from '@/components/shared/atoms/Loader';
7 | import Alert from '@/components/shared/atoms/Alert';
8 |
9 | import {ERROR_ALERT_PROPS} from '@/constants';
10 |
11 | import {useGetUsers} from '@/lib/react-query/queries/user.query';
12 |
13 | const RightSidebar = () => {
14 | const pathname = usePathname();
15 | const {data: creators, isLoading: isUserLoading, isError: isCreatorsError} = useGetUsers(10);
16 |
17 | if (isCreatorsError) return ;
18 |
19 | if (isUserLoading && !creators) {
20 | return (
21 |
22 |
23 |
24 | );
25 | }
26 |
27 | return (
28 |
33 | Top Creators
34 |
35 |
36 | {creators?.documents.map((creator: any) => )}
37 |
38 |
39 | );
40 | };
41 |
42 | export default RightSidebar;
43 |
--------------------------------------------------------------------------------
/components/scenes/RecentPosts.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import PostCard from '@/components/cards/PostCard';
4 | import Loader from '@/components/shared/atoms/Loader';
5 | import Alert from '@/components/shared/atoms/Alert';
6 |
7 | import {useGetRecentPosts} from '@/lib/react-query/queries/post.query';
8 |
9 | import {ERROR_ALERT_PROPS} from '@/constants';
10 |
11 | import type {Models} from 'appwrite';
12 |
13 | const RecentPosts = () => {
14 | const {data: posts, isPending: isPostLoading, isError: isPostError} = useGetRecentPosts();
15 |
16 | if (isPostError) return ;
17 |
18 | return (
19 | <>
20 | {isPostLoading && !posts ? (
21 |
22 | ) : (
23 |
24 | {posts?.documents.length === 0 ? (
25 |
34 | ) : (
35 | <>
36 | {posts?.documents.map((post: Models.Document) => (
37 |
38 | ))}
39 | >
40 | )}
41 |
42 | )}
43 | >
44 | );
45 | };
46 |
47 | export default RecentPosts;
48 |
--------------------------------------------------------------------------------
/lib/react-query/queries/post.query.ts:
--------------------------------------------------------------------------------
1 | import {useInfiniteQuery, useQuery} from '@tanstack/react-query';
2 |
3 | import {
4 | getInfinitePosts,
5 | getPostById,
6 | getRecentPosts,
7 | searchPosts,
8 | } from '@/appwrite/actions/post.action';
9 |
10 | import QUERY_KEYS from '@/lib/react-query/QueryKeys';
11 |
12 | export const useGetPostById = (postId: string) => {
13 | return useQuery({
14 | queryKey: [QUERY_KEYS.GET_POST_BY_ID, postId],
15 | queryFn: () => getPostById(postId),
16 | enabled: !!postId,
17 | });
18 | };
19 |
20 | export const useGetRecentPosts = () => {
21 | return useQuery({
22 | queryKey: [QUERY_KEYS.GET_RECENT_POSTS],
23 | queryFn: getRecentPosts,
24 | });
25 | };
26 |
27 | export const useGetPosts = () => {
28 | return useInfiniteQuery({
29 | queryKey: [QUERY_KEYS.GET_INFINITE_POSTS],
30 | queryFn: getInfinitePosts,
31 | initialPageParam: 0,
32 | getNextPageParam: (lastPage: any) => {
33 | // If there's no data, there are no more pages.
34 | if (lastPage && lastPage.documents.length === 0) {
35 | return null;
36 | }
37 |
38 | // Use the $id of the last document as the cursor.
39 | const lastId = lastPage.documents[lastPage.documents.length - 1].$id;
40 | return lastId;
41 | },
42 | });
43 | };
44 |
45 | export const useSearchPosts = (searchTerm: string) => {
46 | return useQuery({
47 | queryKey: [QUERY_KEYS.SEARCH_POSTS, searchTerm],
48 | queryFn: () => searchPosts(searchTerm),
49 | enabled: !!searchTerm,
50 | });
51 | };
52 |
--------------------------------------------------------------------------------
/public/assets/icons/like.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export type INavLink = {
4 | imgURL: string;
5 | route: string;
6 | label: string;
7 | };
8 |
9 | export type IUpdateUser = {
10 | userId: string;
11 | name: string;
12 | username: string;
13 | bio: string;
14 | email: string;
15 | imageId?: string;
16 | imageUrl?: URL | string;
17 | file: File[];
18 | };
19 |
20 | export type INewPost = {
21 | userId: string;
22 | caption: string;
23 | file: File[];
24 | location?: string;
25 | tags?: string;
26 | };
27 |
28 | export type IUpdatePost = {
29 | postId: string;
30 | caption: string;
31 | imageId: string;
32 | imageUrl: URL;
33 | file: File[];
34 | location?: string;
35 | tags?: string;
36 | };
37 |
38 | export type IUser = {
39 | id: string;
40 | name: string;
41 | username: string;
42 | email: string;
43 | imageUrl: string;
44 | bio: string;
45 | };
46 |
47 | export type INewUser = {
48 | name: string;
49 | email: string;
50 | username: string;
51 | password: string;
52 | };
53 |
54 | export type IContextType = {
55 | user: IUser;
56 | isLoading: boolean;
57 | setUser: React.Dispatch>;
58 | isAuthenticated: boolean;
59 | setIsAuthenticated: React.Dispatch>;
60 | checkAuthUser: () => Promise;
61 | };
62 |
63 | export interface UrlQueryParams {
64 | params: string;
65 | key: string;
66 | value: string | null;
67 | }
68 |
69 | export interface RemoveUrlQueryParams {
70 | params: string;
71 | keysToRemove: string[];
72 | }
73 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs14-snapshot",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "^3.3.4",
13 | "@radix-ui/react-dialog": "^1.0.5",
14 | "@radix-ui/react-label": "^2.0.2",
15 | "@radix-ui/react-slot": "^1.0.2",
16 | "@radix-ui/react-tabs": "^1.0.4",
17 | "@radix-ui/react-toast": "^1.1.5",
18 | "@tanstack/react-query": "^5.20.5",
19 | "@tanstack/react-query-devtools": "^5.24.1",
20 | "appwrite": "^13.0.2",
21 | "class-variance-authority": "^0.7.0",
22 | "clsx": "^2.1.0",
23 | "eslint-config-prettier": "^9.1.0",
24 | "eslint-config-standard": "^17.1.0",
25 | "eslint-plugin-tailwindcss": "^3.14.3",
26 | "lucide-react": "^0.340.0",
27 | "next": "14.2.35",
28 | "prettier": "^3.2.5",
29 | "query-string": "^8.2.0",
30 | "react": "^18",
31 | "react-dom": "^18",
32 | "react-dropzone": "^14.2.3",
33 | "react-hook-form": "^7.50.1",
34 | "react-intersection-observer": "^9.8.1",
35 | "tailwind-merge": "^2.2.1",
36 | "tailwindcss-animate": "^1.0.7",
37 | "zod": "^3.22.4"
38 | },
39 | "devDependencies": {
40 | "@types/node": "^20",
41 | "@types/react": "^18",
42 | "@types/react-dom": "^18",
43 | "autoprefixer": "^10.0.1",
44 | "eslint": "^8",
45 | "eslint-config-next": "14.0.1",
46 | "postcss": "^8",
47 | "tailwindcss": "^3.3.0",
48 | "typescript": "^5"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/public/assets/icons/follow.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/components/cards/FollowCard.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from 'next/image';
4 | import {useRouter} from 'next/navigation';
5 |
6 | import {Button} from '@/components/ui/button';
7 | import Loader from '@/components/shared/atoms/Loader';
8 |
9 | import {useGetUserById} from '@/lib/react-query/queries/user.query';
10 |
11 | type Props = {
12 | followerId: string;
13 | };
14 |
15 | const FollowCard = ({followerId}: Props) => {
16 | const router = useRouter();
17 |
18 | const {data: user, isPending: isUserPending} = useGetUserById(followerId);
19 |
20 | if (isUserPending || !user) {
21 | return (
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | return (
29 |
30 |
31 |
32 |
38 |
39 |
40 |
41 |
{user.name}
42 |
@{user.username}
43 |
44 |
45 |
46 |
49 |
50 | );
51 | };
52 |
53 | export default FollowCard;
54 |
--------------------------------------------------------------------------------
/components/shared/Story.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from 'next/image';
4 |
5 | type Props = {
6 | isUser?: boolean;
7 | };
8 |
9 | const Story = ({isUser = false}: Props) => {
10 | return (
11 |
12 |
26 | {isUser && (
27 |
28 |
35 |
36 | )}
37 |
38 |
43 | Username
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default Story;
51 |
--------------------------------------------------------------------------------
/public/assets/icons/chat.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/components/shared/layout/Topbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {useEffect} from 'react';
4 | import Link from 'next/link';
5 | import Image from 'next/image';
6 | import {useRouter} from 'next/navigation';
7 |
8 | import {useUserContext} from '@/context/AuthContext';
9 |
10 | import {Button} from '@/components/ui/button';
11 |
12 | import {useSignOutAccount} from '@/lib/react-query/mutations/user.mutation';
13 |
14 | const Topbar = () => {
15 | const router = useRouter();
16 |
17 | const {user} = useUserContext();
18 |
19 | const {mutate: signOut, isSuccess} = useSignOutAccount();
20 |
21 | useEffect(() => {
22 | if (isSuccess) {
23 | router.push('/');
24 | }
25 | }, [isSuccess, router]);
26 |
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
38 |
39 |
40 |
47 |
48 |
49 |
50 |
51 | );
52 | };
53 |
54 | export default Topbar;
55 |
--------------------------------------------------------------------------------
/public/assets/icons/back.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/share.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/components/shared/GridPostList.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import Image from 'next/image';
5 |
6 | import {useUserContext} from '@/context/AuthContext';
7 |
8 | import PostStats from '@/components/shared/PostStats';
9 |
10 | import type {Models} from 'appwrite';
11 |
12 | type Props = {
13 | posts: Models.Document[];
14 | showUser?: boolean;
15 | showStats?: boolean;
16 | };
17 |
18 | const GridPostList = ({posts, showUser = true, showStats = true}: Props) => {
19 | const {user} = useUserContext();
20 |
21 | return (
22 | <>
23 | {posts && (
24 |
25 | {posts.map(post => (
26 | -
27 |
28 |
35 |
36 |
37 |
38 | {showUser && (
39 |
40 |
47 |
{post.creator.name}
48 |
49 | )}
50 |
51 | {showStats &&
}
52 |
53 |
54 | ))}
55 |
56 | )}
57 | >
58 | );
59 | };
60 |
61 | export default GridPostList;
62 |
--------------------------------------------------------------------------------
/components/shared/search/LocalSearchbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {useEffect, useState} from 'react';
4 | import Image from 'next/image';
5 | import {usePathname, useRouter, useSearchParams} from 'next/navigation';
6 |
7 | import {Input} from '@/components/ui/input';
8 |
9 | import {formUrlQuery, removeKeysFromQuery} from '@/lib/utils';
10 |
11 | type Props = {
12 | route: string;
13 | placeholder: string;
14 | otherClasses?: string;
15 | };
16 |
17 | const LocalSearchbar = ({route, placeholder, otherClasses}: Props) => {
18 | const router = useRouter();
19 | const pathname = usePathname();
20 | const searchParams = useSearchParams();
21 |
22 | const query = searchParams.get('q');
23 |
24 | const [search, setSearch] = useState(query || '');
25 |
26 | useEffect(() => {
27 | const delayDebounceFn = setTimeout(() => {
28 | if (search) {
29 | const newUrl = formUrlQuery({
30 | params: searchParams.toString(),
31 | key: 'q',
32 | value: search,
33 | });
34 |
35 | router.push(newUrl, {scroll: false});
36 | } else {
37 | if (pathname === route) {
38 | const newUrl = removeKeysFromQuery({
39 | params: searchParams.toString(),
40 | keysToRemove: ['q'],
41 | });
42 |
43 | router.push(newUrl, {scroll: false});
44 | }
45 | }
46 | }, 300);
47 |
48 | return () => clearTimeout(delayDebounceFn);
49 | }, [search, route, pathname, router, searchParams, query]);
50 |
51 | return (
52 |
53 |
54 | setSearch(e.target.value)}
60 | />
61 |
62 | );
63 | };
64 |
65 | export default LocalSearchbar;
66 |
--------------------------------------------------------------------------------
/components/scenes/Follows.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from 'next/image';
4 |
5 | import FollowCard from '@/components/cards/FollowCard';
6 | import Loader from '@/components/shared/atoms/Loader';
7 | import Alert from '@/components/shared/atoms/Alert';
8 |
9 | import {useGetUserById} from '@/lib/react-query/queries/user.query';
10 |
11 | type Props = {
12 | type: 'Followers' | 'Following';
13 | viewedId: string;
14 | };
15 |
16 | const Follows = ({type, viewedId}: Props) => {
17 | const {data: viewedUser, isPending: isUserPending} = useGetUserById(viewedId);
18 |
19 | if (isUserPending || !viewedUser) {
20 | return (
21 |
22 |
23 |
24 | );
25 | }
26 |
27 | return (
28 |
29 |
30 |
31 |
38 |
39 | @{viewedUser.username}'s {type}
40 |
41 |
42 | {viewedUser[type.toLowerCase()].length === 0 ? (
43 |
50 | ) : (
51 | <>
52 | {viewedUser[type.toLowerCase()].map((follower: string) => (
53 |
54 | ))}
55 | >
56 | )}
57 |
58 |
59 | );
60 | };
61 |
62 | export default Follows;
63 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type {Config} from 'tailwindcss';
2 |
3 | const config: Config = {
4 | darkMode: ['class'],
5 | content: [
6 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
7 | './components/**/*.{js,ts,jsx,tsx,mdx}',
8 | './app/**/*.{js,ts,jsx,tsx,mdx}',
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: '2rem',
14 | screens: {
15 | '2xl': '1440px',
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | 'primary-500': '#877EFF',
21 | 'primary-600': '#5D5FEF',
22 | 'secondary-500': '#FFB620',
23 | 'tertiary-500': '#0095F6',
24 | 'off-white': '#D0DFFF',
25 | red: '#FF5A5A',
26 | 'dark-1': '#000000',
27 | 'dark-2': '#09090A',
28 | 'dark-3': '#101012',
29 | 'dark-4': '#1F1F22',
30 | 'light-1': '#FFFFFF',
31 | 'light-2': '#EFEFEF',
32 | 'light-3': '#7878A3',
33 | 'light-4': '#5C5C7B',
34 | },
35 | backgroundImage: {
36 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
37 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
38 | },
39 | screens: {
40 | xs: '480px',
41 | },
42 | width: {
43 | '420': '420px',
44 | '465': '465px',
45 | },
46 | fontFamily: {
47 | inter: ['Inter', 'sans-serif'],
48 | },
49 | keyframes: {
50 | 'accordion-down': {
51 | // @ts-expect-error
52 | from: {height: 0},
53 | to: {height: 'var(--radix-accordion-content-height)'},
54 | },
55 | 'accordion-up': {
56 | from: {height: 'var(--radix-accordion-content-height)'},
57 | // @ts-expect-error
58 | to: {height: 0},
59 | },
60 | },
61 | animation: {
62 | 'accordion-down': 'accordion-down 0.2s ease-out',
63 | 'accordion-up': 'accordion-up 0.2s ease-out',
64 | },
65 | },
66 | },
67 | plugins: [require('tailwindcss-animate')],
68 | };
69 | export default config;
70 |
--------------------------------------------------------------------------------
/public/assets/icons/people.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/lib/react-query/mutations/post.mutation.ts:
--------------------------------------------------------------------------------
1 | import {useQueryClient, useMutation} from '@tanstack/react-query';
2 |
3 | import {createPost, updatePost, deletePost, likePost} from '@/appwrite/actions/post.action';
4 |
5 | import QUERY_KEYS from '@/lib/react-query/QueryKeys';
6 |
7 | import type {INewPost, IUpdatePost} from '@/types';
8 |
9 | export const useCreatePost = () => {
10 | const queryClient = useQueryClient();
11 |
12 | return useMutation({
13 | mutationFn: (post: INewPost) => createPost(post),
14 | onSuccess: () => {
15 | queryClient.invalidateQueries({
16 | queryKey: [QUERY_KEYS.GET_RECENT_POSTS],
17 | });
18 | },
19 | });
20 | };
21 |
22 | export const useUpdatePost = () => {
23 | const queryClient = useQueryClient();
24 |
25 | return useMutation({
26 | mutationFn: (post: IUpdatePost) => updatePost(post),
27 | onSuccess: data => {
28 | queryClient.invalidateQueries({
29 | queryKey: [QUERY_KEYS.GET_POST_BY_ID, data?.$id],
30 | });
31 | },
32 | });
33 | };
34 |
35 | export const useDeletePost = () => {
36 | const queryClient = useQueryClient();
37 |
38 | return useMutation({
39 | mutationFn: ({postId, imageId}: {postId: string; imageId: string}) =>
40 | deletePost(postId, imageId),
41 | onSuccess: () => {
42 | queryClient.invalidateQueries({
43 | queryKey: [QUERY_KEYS.GET_RECENT_POSTS],
44 | });
45 | },
46 | });
47 | };
48 |
49 | export const useLikePost = () => {
50 | const queryClient = useQueryClient();
51 |
52 | return useMutation({
53 | mutationFn: ({postId, likes}: {postId: string; likes: string[]}) => likePost(postId, likes),
54 | onSuccess: data => {
55 | queryClient.invalidateQueries({
56 | queryKey: [QUERY_KEYS.GET_POST_BY_ID, data?.$id],
57 | });
58 | queryClient.invalidateQueries({
59 | queryKey: [QUERY_KEYS.GET_RECENT_POSTS],
60 | });
61 | queryClient.invalidateQueries({
62 | queryKey: [QUERY_KEYS.GET_POSTS],
63 | });
64 | queryClient.invalidateQueries({
65 | queryKey: [QUERY_KEYS.GET_CURRENT_USER],
66 | });
67 | },
68 | });
69 | };
70 |
--------------------------------------------------------------------------------
/public/assets/icons/home.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/assets/icons/gallery-add.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/assets/icons/add-post.svg:
--------------------------------------------------------------------------------
1 |
5 |
6 |
--------------------------------------------------------------------------------
/context/AuthContext.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, {createContext, useContext, useEffect, useState} from 'react';
4 | import {useRouter} from 'next/navigation';
5 |
6 | import {getCurrentUser} from '@/appwrite/actions/user.action';
7 |
8 | import type {IContextType, IUser} from '@/types';
9 |
10 | export const INITIAL_USER = {
11 | id: '',
12 | name: '',
13 | username: '',
14 | email: '',
15 | imageUrl: '',
16 | bio: '',
17 | };
18 |
19 | const INITIAL_STATE: IContextType = {
20 | user: INITIAL_USER,
21 | isLoading: false,
22 | isAuthenticated: false,
23 | setUser: () => {},
24 | setIsAuthenticated: () => {},
25 | checkAuthUser: async () => false as boolean,
26 | };
27 |
28 | const AuthContext = createContext(INITIAL_STATE);
29 |
30 | const AuthProvider = ({children}: {children: React.ReactNode}) => {
31 | const router = useRouter();
32 |
33 | const [user, setUser] = useState(INITIAL_USER);
34 | const [isLoading, setIsLoading] = useState(false);
35 | const [isAuthenticated, setIsAuthenticated] = useState(false);
36 |
37 | const checkAuthUser = async () => {
38 | setIsLoading(true);
39 | try {
40 | const currentAccount = await getCurrentUser();
41 |
42 | if (currentAccount) {
43 | setUser({
44 | id: currentAccount.$id,
45 | name: currentAccount.name,
46 | username: currentAccount.name,
47 | email: currentAccount.email,
48 | imageUrl: currentAccount.imageUrl,
49 | bio: currentAccount.bio,
50 | });
51 |
52 | setIsAuthenticated(true);
53 |
54 | return true;
55 | }
56 |
57 | return false;
58 | } catch (error: any) {
59 | console.log(error);
60 | } finally {
61 | setIsLoading(false);
62 | }
63 | };
64 |
65 | useEffect(() => {
66 | if (
67 | localStorage.getItem('cookieFallback') === '[]' ||
68 | localStorage.getItem('cookieFallback') === null
69 | ) {
70 | router.push('/sign-in');
71 | }
72 |
73 | checkAuthUser();
74 | }, [router]);
75 |
76 | const value = {
77 | user,
78 | setUser,
79 | isLoading,
80 | isAuthenticated,
81 | setIsAuthenticated,
82 | checkAuthUser,
83 | };
84 |
85 | // @ts-ignore
86 | return {children};
87 | };
88 |
89 | export default AuthProvider;
90 |
91 | export const useUserContext = () => useContext(AuthContext);
92 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import {twMerge} from 'tailwind-merge';
2 | import {type ClassValue, clsx} from 'clsx';
3 | import qs from 'query-string';
4 |
5 | import type {UrlQueryParams, RemoveUrlQueryParams} from '@/types';
6 |
7 | export function cn(...inputs: ClassValue[]) {
8 | return twMerge(clsx(inputs));
9 | }
10 |
11 | export const getTimestamp = (createdAt: Date | string): string => {
12 | const now: Date = new Date();
13 |
14 | let timeDifference: number;
15 |
16 | if (createdAt instanceof Date) {
17 | timeDifference = now.getTime() - createdAt.getTime();
18 | } else {
19 | timeDifference = now.getTime() - new Date(createdAt).getTime();
20 | }
21 |
22 | // Define time intervals in milliseconds
23 | const timeUnits: {
24 | unit: string;
25 | milliseconds: number;
26 | }[] = [
27 | {unit: 'year', milliseconds: 365 * 24 * 60 * 60 * 1000},
28 | {unit: 'month', milliseconds: 30 * 24 * 60 * 60 * 1000},
29 | {unit: 'week', milliseconds: 7 * 24 * 60 * 60 * 1000},
30 | {unit: 'day', milliseconds: 24 * 60 * 60 * 1000},
31 | {unit: 'hour', milliseconds: 60 * 60 * 1000},
32 | {unit: 'minute', milliseconds: 60 * 1000},
33 | {unit: 'second', milliseconds: 1000},
34 | ];
35 |
36 | for (const {unit, milliseconds} of timeUnits) {
37 | const time: number = Math.floor(timeDifference / milliseconds);
38 | if (time >= 1) {
39 | return `${time} ${unit}${time === 1 ? 's' : ''} ago`;
40 | }
41 | }
42 |
43 | return 'Just now';
44 | };
45 |
46 | export const formUrlQuery = ({params, key, value}: UrlQueryParams): string => {
47 | const currentUrl = qs.parse(params);
48 |
49 | currentUrl[key] = value;
50 |
51 | return qs.stringifyUrl(
52 | {
53 | url: window.location.pathname,
54 | query: currentUrl,
55 | },
56 | {skipNull: true}
57 | );
58 | };
59 |
60 | export const removeKeysFromQuery = ({params, keysToRemove}: RemoveUrlQueryParams): string => {
61 | const currentUrl = qs.parse(params);
62 |
63 | keysToRemove.forEach(key => {
64 | delete currentUrl[key];
65 | });
66 |
67 | return qs.stringifyUrl(
68 | {
69 | url: window.location.pathname,
70 | query: currentUrl,
71 | },
72 | {skipNull: true}
73 | );
74 | };
75 |
76 | export const getLocaleDate = (isoDate: Date | string): string => {
77 | const date: Date = new Date(isoDate);
78 |
79 | const day: number = date.getDate();
80 | const month = date.toLocaleString('en-US', {month: 'long', minute: 'numeric', hour: 'numeric'});
81 |
82 | return `${day} ${month}`;
83 | };
84 |
--------------------------------------------------------------------------------
/components/cards/PostCard.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import Image from 'next/image';
3 |
4 | import {useUserContext} from '@/context/AuthContext';
5 |
6 | import PostStats from '@/components/shared/PostStats';
7 |
8 | import {getLocaleDate} from '@/lib/utils';
9 |
10 | import type {Models} from 'appwrite';
11 |
12 | type Props = {
13 | post: Models.Document;
14 | };
15 |
16 | const PostCard = ({post}: Props) => {
17 | const {user} = useUserContext();
18 |
19 | if (!post.creator) return null;
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
33 |
34 |
35 |
36 |
{post.creator.name}
37 |
38 |
{getLocaleDate(post.$createdAt)}
-
39 |
{post.location}
40 |
41 |
42 |
43 |
44 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
{post.caption}
55 |
56 | {post.tags.map((tag: string) => (
57 | -
58 | #{tag}
59 |
60 | ))}
61 |
62 |
63 |
64 |
71 |
72 |
73 |
74 |
75 | );
76 | };
77 |
78 | export default PostCard;
79 |
--------------------------------------------------------------------------------
/public/assets/icons/bookmark.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/assets/icons/save.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/assets/icons/delete.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/lib/react-query/mutations/user.mutation.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {useMutation, useQueryClient} from '@tanstack/react-query';
4 |
5 | import {
6 | createUserAccount,
7 | signInAccount,
8 | signOutAccount,
9 | updateUserAccount,
10 | updateUserFollowers,
11 | updateUserFollowing,
12 | } from '@/appwrite/actions/user.action';
13 |
14 | import QUERY_KEYS from '@/lib/react-query/QueryKeys';
15 |
16 | import type {INewUser, IUpdateUser} from '@/types';
17 |
18 | export const useCreateUserAccount = () => {
19 | return useMutation({
20 | mutationFn: (user: INewUser) => createUserAccount(user),
21 | });
22 | };
23 |
24 | export const useSignInAccount = () => {
25 | return useMutation({
26 | mutationFn: (user: {email: string; password: string}) => signInAccount(user),
27 | });
28 | };
29 |
30 | export const useSignOutAccount = () => {
31 | return useMutation({
32 | mutationFn: signOutAccount,
33 | });
34 | };
35 |
36 | export const useUpdateUserAccount = () => {
37 | const queryClient = useQueryClient();
38 | return useMutation({
39 | mutationFn: (user: IUpdateUser) => updateUserAccount(user),
40 | onSuccess: data => {
41 | queryClient.invalidateQueries({
42 | queryKey: [QUERY_KEYS.GET_CURRENT_USER],
43 | });
44 | queryClient.invalidateQueries({
45 | queryKey: [QUERY_KEYS.GET_USER_BY_ID, data?.$id],
46 | });
47 | },
48 | });
49 | };
50 |
51 | export const useUpdateUserFollowers = () => {
52 | const queryClient = useQueryClient();
53 | return useMutation({
54 | mutationFn: ({userId, followers}: {userId: string; followers: string[]}) =>
55 | updateUserFollowers(userId, followers),
56 | onSuccess: data => {
57 | queryClient.invalidateQueries({
58 | queryKey: [QUERY_KEYS.GET_USER_BY_ID, data?.$id],
59 | });
60 | queryClient.invalidateQueries({
61 | queryKey: [QUERY_KEYS.GET_CURRENT_USER],
62 | });
63 | queryClient.invalidateQueries({
64 | queryKey: [QUERY_KEYS.GET_USERS],
65 | });
66 | },
67 | });
68 | };
69 |
70 | export const useUpdateUserFollowing = () => {
71 | const queryClient = useQueryClient();
72 | return useMutation({
73 | mutationFn: ({userId, following}: {userId: string; following: string[]}) =>
74 | updateUserFollowing(userId, following),
75 | onSuccess: data => {
76 | queryClient.invalidateQueries({
77 | queryKey: [QUERY_KEYS.GET_USER_BY_ID, data?.$id],
78 | });
79 | queryClient.invalidateQueries({
80 | queryKey: [QUERY_KEYS.GET_CURRENT_USER],
81 | });
82 | queryClient.invalidateQueries({
83 | queryKey: [QUERY_KEYS.GET_USERS],
84 | });
85 | },
86 | });
87 | };
88 |
--------------------------------------------------------------------------------
/components/cards/UserCard.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 | import Image from 'next/image';
4 |
5 | import {Button} from '@/components/ui/button';
6 | import Loader from '@/components/shared/atoms/Loader';
7 |
8 | import {
9 | useUpdateUserFollowers,
10 | useUpdateUserFollowing,
11 | } from '@/lib/react-query/mutations/user.mutation';
12 | import {useGetCurrentUser} from '@/lib/react-query/queries/user.query';
13 |
14 | import type {Models} from 'appwrite';
15 |
16 | type Props = {
17 | user: Models.Document;
18 | };
19 |
20 | const UserCard = ({user}: Props) => {
21 | const {data: currentUser} = useGetCurrentUser();
22 |
23 | const {mutate: updateUserFollowers} = useUpdateUserFollowers();
24 | const {mutate: updateUserFollowing} = useUpdateUserFollowing();
25 |
26 | if (!currentUser) {
27 | return (
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | const handleFollowUser = (e: React.MouseEvent) => {
35 | e.preventDefault();
36 |
37 | let newFollowers = [...user.followers];
38 | let newFollowing = [...currentUser.following];
39 |
40 | if (newFollowers.includes(currentUser.$id) && newFollowing.includes(user.$id)) {
41 | newFollowers = newFollowers.filter(user => user !== currentUser.$id);
42 | newFollowing = newFollowing.filter(user => user !== user.$id);
43 | } else {
44 | newFollowers.push(currentUser.$id);
45 | newFollowing.push(user.$id);
46 | }
47 |
48 | updateUserFollowers({userId: user.$id, followers: newFollowers});
49 | updateUserFollowing({userId: currentUser.$id, following: newFollowing});
50 | };
51 |
52 | return (
53 |
54 |
61 |
62 |
63 |
{user.name}
64 |
@{user.username}
65 |
66 |
67 |
75 |
76 | );
77 | };
78 |
79 | const isUserFollow = (followList: string[], userId: string) => followList.includes(userId);
80 |
81 | export default UserCard;
82 |
--------------------------------------------------------------------------------
/public/assets/icons/edit.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/file-upload.svg:
--------------------------------------------------------------------------------
1 |
6 |
7 |
--------------------------------------------------------------------------------
/components/shared/search/LocalResult.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {useEffect} from 'react';
4 | import {useSearchParams} from 'next/navigation';
5 | import {useInView} from 'react-intersection-observer';
6 |
7 | import useDebounce from '@/hooks/useDebounce';
8 |
9 | import GridPostList from '@/components/shared/GridPostList';
10 | import Loader from '@/components/shared/atoms/Loader';
11 | import Alert from '@/components/shared/atoms/Alert';
12 |
13 | import {useGetPosts, useSearchPosts} from '@/lib/react-query/queries/post.query';
14 |
15 | const LocalResult = () => {
16 | const searchParams = useSearchParams();
17 | const {ref, inView} = useInView();
18 |
19 | const query = searchParams.get('q');
20 | const debouncedValue = useDebounce(query?.toString() || '', 500);
21 |
22 | const {data: posts, fetchNextPage, hasNextPage} = useGetPosts();
23 | const {data: searchedPosts, isFetching: isSearchFetching} = useSearchPosts(debouncedValue);
24 |
25 | useEffect(() => {
26 | if (inView && !query) fetchNextPage();
27 | }, [inView, query, fetchNextPage]);
28 |
29 | if (!posts || isSearchFetching) {
30 | return (
31 |
32 |
33 |
34 | );
35 | }
36 | const shouldShowSearchResults = query !== undefined && query !== null;
37 | const shouldShowPosts =
38 | !shouldShowSearchResults && posts?.pages.every((item: any) => item.documents.length === 0);
39 |
40 | return (
41 | <>
42 |
43 | {shouldShowSearchResults ? (
44 | searchedPosts && searchedPosts.documents.length > 0 ? (
45 |
46 | ) : (
47 |
54 | )
55 | ) : shouldShowPosts ? (
56 |
65 | ) : (
66 | posts.pages.map((item: any, index) => (
67 |
68 | ))
69 | )}
70 |
71 |
72 | {hasNextPage && !query && (
73 |
74 |
75 |
76 | )}
77 | >
78 | );
79 | };
80 |
81 | export default LocalResult;
82 |
--------------------------------------------------------------------------------
/components/shared/FileUploader.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {useState, useCallback} from 'react';
4 | import Image from 'next/image';
5 |
6 | import {type FileWithPath, useDropzone} from 'react-dropzone';
7 |
8 | import {Button} from '@/components/ui/button';
9 |
10 | type FileUploaderProps = {
11 | type: 'User' | 'Post';
12 | fieldChange: (files: File[]) => void;
13 | mediaUrl: string;
14 | };
15 |
16 | const FileUploader = ({type, fieldChange, mediaUrl}: FileUploaderProps) => {
17 | // eslint-disable-next-line no-unused-vars
18 | const [file, setFile] = useState([]);
19 | const [fileUrl, setFileUrl] = useState(mediaUrl);
20 |
21 | const onDrop = useCallback(
22 | (acceptedFiles: FileWithPath[]) => {
23 | setFile(acceptedFiles);
24 | fieldChange(acceptedFiles);
25 | setFileUrl(URL.createObjectURL(acceptedFiles[0]));
26 | },
27 | [fieldChange]
28 | );
29 |
30 | const {getRootProps, getInputProps} = useDropzone({
31 | onDrop,
32 | accept: {
33 | 'image/*': ['.png', '.jpeg', '.jpg', '.svg'],
34 | },
35 | });
36 |
37 | if (type === 'User') {
38 | return (
39 |
40 |
41 |
42 |
43 |
50 |
Change profile photo
51 |
52 |
53 | );
54 | } else if (type === 'Post') {
55 | return (
56 |
60 |
61 | {fileUrl ? (
62 | <>
63 |
64 |
71 |
72 |
Click or drag photo to replace
73 | >
74 | ) : (
75 |
76 |
77 |
Drap photo here
78 |
SVG, PNG, JPG
79 |
80 |
81 |
82 | )}
83 |
84 | );
85 | }
86 | };
87 |
88 | export default FileUploader;
89 |
--------------------------------------------------------------------------------
/public/assets/icons/logout.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/components/shared/layout/LeftSidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {useEffect} from 'react';
4 | import Link from 'next/link';
5 | import Image from 'next/image';
6 | import {usePathname, useRouter} from 'next/navigation';
7 |
8 | import {useUserContext} from '@/context/AuthContext';
9 |
10 | import {Button} from '@/components/ui/button';
11 |
12 | import {useSignOutAccount} from '@/lib/react-query/mutations/user.mutation';
13 |
14 | import {sidebarLinks} from '@/constants';
15 |
16 | import type {INavLink} from '@/types';
17 |
18 | const LeftSidebar = () => {
19 | const router = useRouter();
20 | const pathname = usePathname();
21 |
22 | const {user} = useUserContext();
23 |
24 | const {mutate: signOut, isSuccess} = useSignOutAccount();
25 |
26 | useEffect(() => {
27 | if (isSuccess) {
28 | router.push('/sign-in');
29 | }
30 | }, [isSuccess, router]);
31 |
32 | return (
33 |
86 | );
87 | };
88 |
89 | export default LeftSidebar;
90 |
--------------------------------------------------------------------------------
/components/shared/PostStats.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, {useState, useEffect} from 'react';
4 | import Image from 'next/image';
5 |
6 | import Loader from '@/components/shared/atoms/Loader';
7 |
8 | import {useDeleteSavedPost, useSavePost} from '@/lib/react-query/mutations/save.mutation';
9 | import {useLikePost} from '@/lib/react-query/mutations/post.mutation';
10 | import {useGetCurrentUser} from '@/lib/react-query/queries/user.query';
11 |
12 | import type {Models} from 'appwrite';
13 |
14 | type PostStatsProps = {
15 | post: Models.Document;
16 | userId: string;
17 | };
18 |
19 | type PostStatsIconProps = {
20 | icon: string;
21 | onClick: (e: React.MouseEvent) => void;
22 | };
23 |
24 | const PostStatsIcon = ({icon, onClick}: PostStatsIconProps) => (
25 |
33 | );
34 |
35 | const PostStats = ({post, userId}: PostStatsProps) => {
36 | const likesList = post.likes.map((user: Models.Document) => user.$id);
37 |
38 | const [likes, setLikes] = useState(likesList);
39 | const [isSaved, setIsSaved] = useState(false);
40 |
41 | const {data: currentUser} = useGetCurrentUser();
42 |
43 | const {mutate: likePost} = useLikePost();
44 | const {mutate: savePost, isPending: isSavingPost} = useSavePost();
45 | const {mutate: deleteSavedPost, isPending: isDeletingSavedPost} = useDeleteSavedPost();
46 |
47 | const savedPostRecord = currentUser?.save.find(
48 | (record: Models.Document) => record.post.$id === post.$id
49 | );
50 |
51 | useEffect(() => setIsSaved(!!savedPostRecord), [currentUser, savedPostRecord]);
52 |
53 | const handleLikePost = (e: React.MouseEvent) => {
54 | e.stopPropagation();
55 |
56 | let newLikes = [...likes];
57 |
58 | if (isUserLiked(newLikes, userId)) {
59 | newLikes = newLikes.filter(user => user !== userId);
60 | } else {
61 | newLikes.push(userId);
62 | }
63 |
64 | setLikes(newLikes);
65 | likePost({postId: post.$id, likes: newLikes});
66 | };
67 |
68 | const handleSavePost = (e: React.MouseEvent) => {
69 | e.stopPropagation();
70 |
71 | if (savedPostRecord) {
72 | setIsSaved(false);
73 | deleteSavedPost(savedPostRecord.$id);
74 | } else {
75 | savePost({postId: post.$id, userId});
76 | setIsSaved(true);
77 | }
78 | };
79 |
80 | return (
81 |
82 |
83 |
87 |
{likes.length}
88 |
89 |
90 | {isSavingPost || isDeletingSavedPost ? (
91 |
92 | ) : (
93 |
94 | )}
95 |
96 |
97 | );
98 | };
99 |
100 | const isUserLiked = (likeList: string[], userId: string) => likeList.includes(userId);
101 |
102 | export default PostStats;
103 |
--------------------------------------------------------------------------------
/public/assets/icons/wallpaper.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "@/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_VALUE
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ToastPrimitives from '@radix-ui/react-toast';
3 | import {cva, type VariantProps} from 'class-variance-authority';
4 | import {X} from 'lucide-react';
5 |
6 | import {cn} from '@/lib/utils';
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({className, ...props}, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
27 | {
28 | variants: {
29 | variant: {
30 | default: 'bg-dark-2 border-0 text-slate-50',
31 | destructive:
32 | 'destructive group border-destructive bg-destructive text-destructive-foreground',
33 | },
34 | },
35 | defaultVariants: {
36 | variant: 'default',
37 | },
38 | }
39 | );
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef & VariantProps
44 | >(({className, variant, ...props}, ref) => {
45 | return (
46 |
51 | );
52 | });
53 | Toast.displayName = ToastPrimitives.Root.displayName;
54 |
55 | const ToastAction = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({className, ...props}, ref) => (
59 |
67 | ));
68 | ToastAction.displayName = ToastPrimitives.Action.displayName;
69 |
70 | const ToastClose = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({className, ...props}, ref) => (
74 |
83 |
84 |
85 | ));
86 | ToastClose.displayName = ToastPrimitives.Close.displayName;
87 |
88 | const ToastTitle = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({className, ...props}, ref) => (
92 |
93 | ));
94 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
95 |
96 | const ToastDescription = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({className, ...props}, ref) => (
100 |
105 | ));
106 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
107 |
108 | type ToastProps = React.ComponentPropsWithoutRef;
109 |
110 | type ToastActionElement = React.ReactElement;
111 |
112 | export {
113 | type ToastProps,
114 | type ToastActionElement,
115 | ToastProvider,
116 | ToastViewport,
117 | Toast,
118 | ToastTitle,
119 | ToastDescription,
120 | ToastClose,
121 | ToastAction,
122 | };
123 |
--------------------------------------------------------------------------------
/components/scenes/Post.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import Image from 'next/image';
5 | import Link from 'next/link';
6 | import {useRouter} from 'next/navigation';
7 |
8 | import {useUserContext} from '@/context/AuthContext';
9 |
10 | import {Button} from '@/components/ui/button';
11 | import PostStats from '@/components/shared/PostStats';
12 | import GridPostList from '@/components/shared/GridPostList';
13 | import Loader from '@/components/shared/atoms/Loader';
14 | import Alert from '@/components/shared/atoms/Alert';
15 |
16 | import {useGetPostById} from '@/lib/react-query/queries/post.query';
17 | import {useDeletePost} from '@/lib/react-query/mutations/post.mutation';
18 | import {useGetUserPosts} from '@/lib/react-query/queries/user.query';
19 | import {getLocaleDate} from '@/lib/utils';
20 |
21 | type Props = {
22 | postId: string;
23 | };
24 |
25 | const Post = ({postId}: Props) => {
26 | const router = useRouter();
27 | const {user} = useUserContext();
28 | const {data: post, isPending: isPostPending} = useGetPostById((postId as string) || '');
29 | const {data: userPosts, isPending: isUserPostLoading} = useGetUserPosts(post?.creator.$id);
30 |
31 | const {mutate: deletePost} = useDeletePost();
32 |
33 | if (isPostPending) {
34 | return (
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | const handleDeletePost = () => {
42 | deletePost({postId, imageId: post?.imageId});
43 | router.back();
44 | };
45 |
46 | const relatedPosts = userPosts?.documents.filter(userPost => userPost.$id !== postId);
47 |
48 | return (
49 |
50 | {isPostPending || !post ? (
51 |
52 | ) : (
53 |
54 |
61 |
62 |
63 |
64 |
71 |
72 |
73 |
{post.creator.name}
74 |
75 | {post.$createdAt && (
76 |
77 | {getLocaleDate(post.$createdAt)}
78 |
79 | )}
80 | -
{post.location}
81 |
82 |
83 |
84 |
85 |
86 |
90 |
91 |
92 |
93 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
{post.caption}
107 |
108 | {post.tags.map((tag: string) => (
109 | -
110 | #{tag}
111 |
112 | ))}
113 |
114 |
115 |
116 |
119 |
120 |
121 | )}
122 |
123 |
124 |
125 |
More Related Posts
126 | {isUserPostLoading || !relatedPosts ? (
127 |
128 | ) : relatedPosts.length === 0 ? (
129 |
136 | ) : (
137 |
138 | )}
139 |
140 |
141 | );
142 | };
143 |
144 | export default Post;
145 |
--------------------------------------------------------------------------------
/appwrite/actions/user.action.ts:
--------------------------------------------------------------------------------
1 | import {Query} from 'appwrite';
2 |
3 | import {account, avatars, database, ID} from '@/appwrite/client';
4 | import {uploadFile, getFilePreview, deleteFile} from '@/appwrite/actions/post.action';
5 | import appwriteConfig from '@/appwrite/conf';
6 |
7 | import type {INewUser, IUpdateUser} from '@/types';
8 |
9 | export async function createUserAccount(user: INewUser) {
10 | try {
11 | const newAccount = await account.create(ID.unique(), user.email, user.password, user.name);
12 |
13 | if (!newAccount) {
14 | throw new Error('User not created');
15 | }
16 |
17 | const avatarUrl = avatars.getInitials(user.name);
18 |
19 | const newUser = await saveUserToDB({
20 | accountId: newAccount.$id,
21 | email: newAccount.email,
22 | name: newAccount.name,
23 | username: user.username,
24 | imageUrl: avatarUrl,
25 | });
26 |
27 | return newUser;
28 | } catch (error: any) {
29 | console.error(error);
30 | }
31 | }
32 |
33 | export async function updateUserAccount(user: IUpdateUser) {
34 | try {
35 | const hasFileToUpdate = user.file.length > 0;
36 |
37 | let image = {
38 | imageUrl: user.imageUrl,
39 | imageId: user.imageId,
40 | };
41 |
42 | if (hasFileToUpdate) {
43 | // Upload new file to appwrite storage
44 | const uploadedFile = await uploadFile(user.file[0]);
45 |
46 | if (!uploadedFile) {
47 | throw new Error('File not uploaded');
48 | }
49 |
50 | // Get new file url
51 | const fileUrl = getFilePreview(uploadedFile.$id);
52 |
53 | if (!fileUrl) {
54 | await deleteFile(uploadedFile.$id);
55 | throw new Error('File not found');
56 | }
57 |
58 | image = {...image, imageUrl: fileUrl, imageId: uploadedFile.$id};
59 | }
60 |
61 | const updatedUser = await database.updateDocument(
62 | appwriteConfig.databaseId,
63 | appwriteConfig.userCollectionId,
64 | user.userId,
65 | {
66 | name: user.name,
67 | bio: user.bio,
68 | email: user.email,
69 | imageUrl: image.imageUrl,
70 | imageId: image.imageId,
71 | }
72 | );
73 |
74 | if (!updatedUser) {
75 | if (hasFileToUpdate && image.imageId) {
76 | await deleteFile(image.imageId);
77 | }
78 |
79 | throw new Error('User not updated');
80 | }
81 |
82 | if (user.imageId && hasFileToUpdate) {
83 | await deleteFile(user.imageId);
84 | }
85 |
86 | return updatedUser;
87 | } catch (error: any) {
88 | console.error(error);
89 | }
90 | }
91 |
92 | export async function saveUserToDB(user: {
93 | accountId: string;
94 | email: string;
95 | name: string;
96 | imageUrl: URL;
97 | username?: string;
98 | }) {
99 | try {
100 | const newUser = await database.createDocument(
101 | appwriteConfig.databaseId as string,
102 | appwriteConfig.userCollectionId as string,
103 | ID.unique(),
104 | user
105 | );
106 |
107 | if (!newUser) {
108 | throw new Error('User not saved to DB');
109 | }
110 |
111 | return newUser;
112 | } catch (error: any) {
113 | console.error(error);
114 | }
115 | }
116 |
117 | export async function signInAccount(user: {email: string; password: string}) {
118 | try {
119 | const session = await account.createEmailSession(user.email, user.password);
120 |
121 | return session;
122 | } catch (error: any) {
123 | console.error(error);
124 | }
125 | }
126 |
127 | export async function getCurrentUser() {
128 | try {
129 | const currentAccount = await account.get();
130 |
131 | if (!currentAccount) {
132 | throw new Error('No current user');
133 | }
134 |
135 | const currentUser = await database.listDocuments(
136 | appwriteConfig.databaseId,
137 | appwriteConfig.userCollectionId,
138 | [Query.equal('accountId', currentAccount.$id)]
139 | );
140 |
141 | if (!currentUser) {
142 | throw new Error('No user found');
143 | }
144 |
145 | return currentUser.documents[0];
146 | } catch (error: any) {
147 | console.error(error);
148 | }
149 | }
150 |
151 | export async function signOutAccount() {
152 | try {
153 | const session = await account.deleteSession('current');
154 |
155 | return session;
156 | } catch (error: any) {
157 | console.error(error);
158 | }
159 | }
160 |
161 | export async function getUsers(limit?: number) {
162 | try {
163 | const users = await database.listDocuments(
164 | appwriteConfig.databaseId,
165 | appwriteConfig.userCollectionId,
166 | [Query.orderDesc('$createdAt'), Query.limit(10)]
167 | );
168 |
169 | if (!users) throw Error;
170 |
171 | return users;
172 | } catch (error) {
173 | console.log(error);
174 | }
175 | }
176 |
177 | export async function getUserById(userId: string) {
178 | try {
179 | const user = await database.getDocument(
180 | appwriteConfig.databaseId,
181 | appwriteConfig.userCollectionId,
182 | userId
183 | );
184 |
185 | if (!user) throw Error;
186 |
187 | return user;
188 | } catch (error) {
189 | console.log(error);
190 | }
191 | }
192 |
193 | export async function updateUserFollowers(userId: string, followers: string[]) {
194 | try {
195 | const user = await database.updateDocument(
196 | appwriteConfig.databaseId,
197 | appwriteConfig.userCollectionId,
198 | userId,
199 | {
200 | followers,
201 | }
202 | );
203 |
204 | if (!user) throw Error;
205 |
206 | return user;
207 | } catch (error) {
208 | console.log(error);
209 | }
210 | }
211 |
212 | export async function updateUserFollowing(userId: string, following: string[]) {
213 | try {
214 | const user = await database.updateDocument(
215 | appwriteConfig.databaseId,
216 | appwriteConfig.userCollectionId,
217 | userId,
218 | {
219 | following,
220 | }
221 | );
222 |
223 | if (!user) throw Error;
224 |
225 | return user;
226 | } catch (error) {
227 | console.log(error);
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/components/forms/Post.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {useRouter} from 'next/navigation';
4 |
5 | import {useForm} from 'react-hook-form';
6 | import {zodResolver} from '@hookform/resolvers/zod';
7 | import {z} from 'zod';
8 |
9 | import {useUserContext} from '@/context/AuthContext';
10 |
11 | import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from '@/components/ui/form';
12 | import {Input} from '@/components/ui/input';
13 | import {Button} from '@/components/ui/button';
14 | import {Textarea} from '@/components/ui/textarea';
15 | import {useToast} from '@/components/ui/use-toast';
16 | import FileUploader from '@/components/shared/FileUploader';
17 | import Loader from '@/components/shared/atoms/Loader';
18 |
19 | import {useCreatePost, useUpdatePost} from '@/lib/react-query/mutations/post.mutation';
20 | import {useGetPostById} from '@/lib/react-query/queries/post.query';
21 | import {PostValidation} from '@/lib/validations';
22 |
23 | type Props = {
24 | action: 'Create' | 'Update';
25 | postId?: string;
26 | };
27 |
28 | const Post = ({action, postId}: Props) => {
29 | const {toast} = useToast();
30 | const router = useRouter();
31 |
32 | const {user} = useUserContext();
33 | const {mutateAsync: createPost, isPending: isCreatingPost} = useCreatePost();
34 | const {mutateAsync: updatePost, isPending: isUpdatingPost} = useUpdatePost();
35 | const {data: post, isPending: isPostPending} = useGetPostById((postId as string) || '');
36 |
37 | const form = useForm>({
38 | resolver: zodResolver(PostValidation),
39 | defaultValues: {
40 | caption: post ? post?.caption : '',
41 | file: [],
42 | location: post ? post?.location : '',
43 | tags: post ? post?.tags.join(',') : '',
44 | },
45 | });
46 |
47 | // 2. Define a submit handler.
48 | async function onSubmit(values: z.infer) {
49 | if (post && action === 'Update') {
50 | const updatedPost = await updatePost({
51 | ...values,
52 | postId: post?.$id,
53 | imageId: post?.imageId,
54 | imageUrl: post?.imageUrl,
55 | });
56 |
57 | if (!updatedPost) {
58 | return toast({
59 | title: "Couldn't update post",
60 | description: 'Something went wrong while updating your post.',
61 | });
62 | }
63 |
64 | return router.push(`/posts/${post?.$id}`);
65 | }
66 |
67 | const newPost = await createPost({
68 | ...values,
69 | userId: user.id,
70 | });
71 |
72 | if (!newPost) {
73 | return toast({
74 | title: "Couldn't create post",
75 | description: 'Something went wrong while creating your post.',
76 | });
77 | }
78 |
79 | router.push('/');
80 | }
81 |
82 | if (action === 'Update' && isPostPending) return ;
83 |
84 | return (
85 |
169 |
170 | );
171 | };
172 |
173 | export default Post;
174 |
--------------------------------------------------------------------------------
/components/forms/Profile.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {useRouter} from 'next/navigation';
4 |
5 | import {useForm} from 'react-hook-form';
6 | import {zodResolver} from '@hookform/resolvers/zod';
7 | import {z} from 'zod';
8 |
9 | import {useUserContext} from '@/context/AuthContext';
10 |
11 | import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from '@/components/ui/form';
12 | import {Input} from '@/components/ui/input';
13 | import {Button} from '@/components/ui/button';
14 | import {Textarea} from '@/components/ui/textarea';
15 | import {useToast} from '@/components/ui/use-toast';
16 | import FileUploader from '@/components/shared/FileUploader';
17 | import Loader from '@/components/shared/atoms/Loader';
18 |
19 | import {useGetUserById} from '@/lib/react-query/queries/user.query';
20 | import {useUpdateUserAccount} from '@/lib/react-query/mutations/user.mutation';
21 | import {ProfileValidation} from '@/lib/validations';
22 |
23 | type Props = {
24 | userId: string;
25 | };
26 |
27 | const Profile = ({userId}: Props) => {
28 | const router = useRouter();
29 | const {toast} = useToast();
30 |
31 | const {user, setUser} = useUserContext();
32 |
33 | const {data: currentUser} = useGetUserById(userId || '');
34 |
35 | const {mutateAsync: updateUser, isPending: isUpdatePending} = useUpdateUserAccount();
36 |
37 | const form = useForm>({
38 | resolver: zodResolver(ProfileValidation),
39 | defaultValues: {
40 | file: [],
41 | name: user.name,
42 | username: user.username,
43 | email: user.email,
44 | bio: user.bio || '',
45 | },
46 | });
47 |
48 | async function onSubmit(values: z.infer) {
49 | if (currentUser && currentUser.$id) {
50 | const updatedUser = await updateUser({
51 | userId: currentUser.$id,
52 | name: values.name,
53 | username: values.username,
54 | email: values.email,
55 | bio: values.bio,
56 | file: values.file,
57 | });
58 |
59 | if (!updatedUser) {
60 | return toast({
61 | title: "Couldn't update profile",
62 | description: 'Something went wrong while updating your profile. Please try again.',
63 | });
64 | } else {
65 | setUser({
66 | ...user,
67 | name: updatedUser.name,
68 | username: updatedUser.username,
69 | email: updatedUser.email,
70 | bio: updatedUser.bio,
71 | imageUrl: updatedUser.imageUrl,
72 | });
73 |
74 | router.push(`/profile/${userId}`);
75 | }
76 | }
77 | }
78 |
79 | if (!currentUser) {
80 | return (
81 |
82 |
83 |
84 | );
85 | }
86 |
87 | return (
88 |
185 |
186 | );
187 | };
188 |
189 | export default Profile;
190 |
--------------------------------------------------------------------------------
/components/scenes/Profile.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import Image from 'next/image';
5 |
6 | import {Button} from '@/components/ui/button';
7 | import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
8 | import GridPostList from '@/components/shared/GridPostList';
9 | import Loader from '@/components/shared/atoms/Loader';
10 |
11 | import {
12 | useUpdateUserFollowers,
13 | useUpdateUserFollowing,
14 | } from '@/lib/react-query/mutations/user.mutation';
15 | import {useGetCurrentUser, useGetUserById} from '@/lib/react-query/queries/user.query';
16 |
17 | type Props = {
18 | userId: string;
19 | };
20 |
21 | type StatBlockProps = {
22 | value: string | number;
23 | label: string;
24 | link?: string;
25 | };
26 |
27 | const StatBlock = ({value, label, link}: StatBlockProps) => {
28 | const renderValue = () => (
29 |
30 |
{value}
31 |
{label}
32 |
33 | );
34 |
35 | return link ? {renderValue()} : renderValue();
36 | };
37 |
38 | const Profile = ({userId}: Props) => {
39 | const {data: currentUser} = useGetCurrentUser();
40 |
41 | const {data: viewedUser} = useGetUserById(userId);
42 |
43 | const {mutate: updateUserFollowers} = useUpdateUserFollowers();
44 | const {mutate: updateUserFollowing} = useUpdateUserFollowing();
45 |
46 | if (!viewedUser || !currentUser) {
47 | return (
48 |
49 |
50 |
51 | );
52 | }
53 |
54 | const handleFollowUser = async () => {
55 | let newFollowers = [...viewedUser.followers];
56 | let newFollowing = [...currentUser.following];
57 |
58 | if (newFollowers.includes(currentUser.$id)) {
59 | newFollowers = newFollowers.filter(user => user !== currentUser.$id);
60 | newFollowing = newFollowing.filter(user => user !== viewedUser.$id);
61 | } else {
62 | newFollowers.push(currentUser.$id);
63 | newFollowing.push(viewedUser.$id);
64 | }
65 |
66 | updateUserFollowers({userId: viewedUser.$id, followers: newFollowers});
67 | updateUserFollowing({userId: currentUser.$id, following: newFollowing});
68 | };
69 |
70 | return (
71 |
72 |
73 |
74 |
81 |
82 |
83 |
{viewedUser.name}
84 |
@{viewedUser.username}
85 |
86 |
87 |
88 |
89 |
94 |
99 |
100 |
101 |
{viewedUser.bio}
102 |
103 |
104 |
105 |
106 |
110 |
120 |
121 |
122 |
123 |
124 |
127 |
128 |
129 |
130 |
131 |
132 | {currentUser.$id === viewedUser.$id ? (
133 |
134 |
135 |
136 | {' '}
137 |
138 | Posts
139 |
140 |
141 | {' '}
142 |
143 | Liked Posts
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 | ) : (
154 |
155 | )}
156 |
157 | );
158 | };
159 |
160 | const isUserFollow = (followList: string[], userId: string) => followList.includes(userId);
161 |
162 | export default Profile;
163 |
--------------------------------------------------------------------------------
/components/forms/Auth.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import Image from 'next/image';
5 | import {useRouter} from 'next/navigation';
6 |
7 | import {zodResolver} from '@hookform/resolvers/zod';
8 | import {useForm} from 'react-hook-form';
9 | import * as z from 'zod';
10 |
11 | import {useUserContext} from '@/context/AuthContext';
12 |
13 | import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from '@/components/ui/form';
14 | import {Button} from '@/components/ui/button';
15 | import {Input} from '@/components/ui/input';
16 | import {useToast} from '@/components/ui/use-toast';
17 | import Loader from '@/components/shared/atoms/Loader';
18 |
19 | import {useCreateUserAccount, useSignInAccount} from '@/lib/react-query/mutations/user.mutation';
20 | import {SignInValidation, SignUpValidation} from '@/lib/validations';
21 |
22 | type Props = {
23 | action: 'SignIn' | 'SignUp';
24 | };
25 |
26 | const getActionData = (action: string) => {
27 | return action === 'SignIn'
28 | ? {
29 | formSchema: SignInValidation,
30 | defaultValues: {
31 | email: '',
32 | password: '',
33 | },
34 | title: 'Sign in to your account',
35 | description: "Welcome back! We're so excited to see you again!",
36 | button: 'Sign In',
37 | link: {
38 | text: "Don't have an account?",
39 | url: '/sign-up',
40 | linkText: 'Sign up',
41 | },
42 | }
43 | : {
44 | formSchema: SignUpValidation,
45 | defaultValues: {
46 | name: '',
47 | username: '',
48 | email: '',
49 | password: '',
50 | },
51 | title: 'Create a new account',
52 | description: "Let's get you all set up! Enter your details below.",
53 | button: 'Sign Up',
54 | link: {
55 | text: 'Already have an account?',
56 | url: '/sign-in',
57 | linkText: 'Sign in',
58 | },
59 | };
60 | };
61 |
62 | const Auth = ({action}: Props) => {
63 | const router = useRouter();
64 | const {toast} = useToast();
65 |
66 | const {formSchema, defaultValues, title, description, button, link} = getActionData(action);
67 |
68 | const isSignUp: boolean = action === 'SignUp';
69 |
70 | const {checkAuthUser, isLoading: isUserLoading} = useUserContext();
71 | const {mutateAsync: createUserAccount, isPending: isCreatingAccount} = useCreateUserAccount();
72 | const {mutateAsync: signInAccount, isPending: isSigningIn} = useSignInAccount();
73 |
74 | // 1. Define your form.
75 | const form = useForm>({
76 | resolver: zodResolver(formSchema),
77 | defaultValues,
78 | });
79 |
80 | // 2. Define a submit handler.
81 | async function onSubmit(values: z.infer) {
82 | try {
83 | if (isSignUp) {
84 | const newUser = await createUserAccount({
85 | // @ts-ignore
86 | name: values.name,
87 | email: values.email,
88 | // @ts-ignore
89 | username: values.username,
90 | password: values.password,
91 | });
92 |
93 | if (!newUser) {
94 | return toast({
95 | title: "Couldn't create account",
96 | description: 'Something went wrong. Please try again.',
97 | });
98 | }
99 | }
100 |
101 | const session = await signInAccount({
102 | email: values.email,
103 | password: values.password,
104 | });
105 |
106 | if (!session) {
107 | return toast({
108 | title: "Couldn't sign in",
109 | description: 'Wrong email or password. Please try again.',
110 | });
111 | }
112 |
113 | const isLoggedIn = await checkAuthUser();
114 |
115 | if (isLoggedIn) {
116 | form.reset();
117 |
118 | router.push('/');
119 | } else {
120 | return toast({
121 | title: "Couldn't sign in",
122 | description: 'Something went wrong. Please try again.',
123 | });
124 | }
125 | } catch (error) {
126 | console.log(error);
127 | }
128 | }
129 |
130 | return (
131 |
217 | );
218 | };
219 |
220 | export default Auth;
221 |
--------------------------------------------------------------------------------
/appwrite/actions/post.action.ts:
--------------------------------------------------------------------------------
1 | import {Query} from 'appwrite';
2 |
3 | import {database, storage, ID} from '@/appwrite/client';
4 | import appwriteConfig from '@/appwrite/conf';
5 |
6 | import type {INewPost, IUpdatePost} from '@/types';
7 |
8 | export async function createPost(post: INewPost) {
9 | try {
10 | // Upload image to storage
11 | const uploadedFile = await uploadFile(post.file[0]);
12 |
13 | if (!uploadedFile) {
14 | throw new Error('File upload failed');
15 | }
16 |
17 | // Get image URL
18 | const fileUrl = getFilePreview(uploadedFile.$id);
19 |
20 | if (!fileUrl) {
21 | await deleteFile(uploadedFile.$id);
22 | throw new Error('File URL not found');
23 | }
24 |
25 | // Convert tags to array
26 | const tags = post.tags?.replace(/ /g, '').split(',') || [];
27 |
28 | // Save post to database
29 | const newPost = await database.createDocument(
30 | appwriteConfig.databaseId,
31 | appwriteConfig.postCollectionId,
32 | ID.unique(),
33 | {
34 | creator: post.userId,
35 | caption: post.caption,
36 | imageUrl: fileUrl,
37 | imageId: uploadedFile.$id,
38 | location: post.location,
39 | tags,
40 | }
41 | );
42 |
43 | if (!newPost) {
44 | await deleteFile(uploadedFile.$id);
45 | throw new Error('Post creation failed');
46 | }
47 |
48 | return newPost;
49 | } catch (error) {
50 | console.error(error);
51 | }
52 | }
53 |
54 | export async function updatePost(post: IUpdatePost) {
55 | const hasFileToUpdate = post.file.length > 0;
56 |
57 | try {
58 | let image = {
59 | imageUrl: post.imageUrl,
60 | imageId: post.imageId,
61 | };
62 |
63 | if (hasFileToUpdate) {
64 | // Upload image to storage
65 | const uploadedFile = await uploadFile(post.file[0]);
66 |
67 | if (!uploadedFile) {
68 | throw new Error('File upload failed');
69 | }
70 |
71 | // Get image URL
72 | const fileUrl = getFilePreview(uploadedFile.$id);
73 |
74 | if (!fileUrl) {
75 | await deleteFile(uploadedFile.$id);
76 | throw new Error('File URL not found');
77 | }
78 |
79 | image = {...image, imageUrl: fileUrl, imageId: uploadedFile.$id};
80 | }
81 |
82 | // Convert tags to array
83 | const tags = post.tags?.replace(/ /g, '').split(',') || [];
84 |
85 | // Save post to database
86 | const updatedPost = await database.updateDocument(
87 | appwriteConfig.databaseId,
88 | appwriteConfig.postCollectionId,
89 | post.postId,
90 | {
91 | caption: post.caption,
92 | imageUrl: image.imageUrl,
93 | imageId: image.imageId,
94 | location: post.location,
95 | tags,
96 | }
97 | );
98 |
99 | if (!updatedPost) {
100 | await deleteFile(post.imageId);
101 | throw new Error('Post creation failed');
102 | }
103 |
104 | return updatedPost;
105 | } catch (error) {
106 | console.error(error);
107 | }
108 | }
109 |
110 | export async function deletePost(postId: string, imageId: string) {
111 | if (!postId || !imageId) {
112 | throw new Error('Missing post or image ID');
113 | }
114 |
115 | try {
116 | const deletedPost = await database.deleteDocument(
117 | appwriteConfig.databaseId,
118 | appwriteConfig.postCollectionId,
119 | postId
120 | );
121 |
122 | if (!deletedPost) {
123 | throw new Error('Post deletion failed');
124 | }
125 |
126 | return deletedPost;
127 | } catch (error) {
128 | console.error(error);
129 | }
130 | }
131 |
132 | export async function deleteFile(fileId: string) {
133 | try {
134 | const deletedFile = await storage.deleteFile(appwriteConfig.storageId, fileId);
135 |
136 | if (!deletedFile) {
137 | throw new Error('File deletion failed');
138 | }
139 |
140 | return deletedFile;
141 | } catch (error) {
142 | console.error(error);
143 | }
144 | }
145 |
146 | export async function uploadFile(file: File) {
147 | try {
148 | const uploadedFile = await storage.createFile(appwriteConfig.storageId, ID.unique(), file);
149 |
150 | return uploadedFile;
151 | } catch (error) {
152 | console.error(error);
153 | }
154 | }
155 |
156 | export function getFilePreview(fileId: string) {
157 | try {
158 | const fileUrl = storage.getFilePreview(
159 | appwriteConfig.storageId,
160 | fileId,
161 | 2000,
162 | 2000,
163 | 'top',
164 | 100
165 | );
166 |
167 | return fileUrl;
168 | } catch (error) {
169 | console.log(error);
170 | }
171 | }
172 |
173 | export async function getPostById(postId: string) {
174 | try {
175 | const post = await database.getDocument(
176 | appwriteConfig.databaseId,
177 | appwriteConfig.postCollectionId,
178 | postId
179 | );
180 |
181 | if (!post) {
182 | throw new Error('Post retrieval failed');
183 | }
184 |
185 | return post;
186 | } catch (error) {
187 | console.error(error);
188 | }
189 | }
190 |
191 | export async function getRecentPosts() {
192 | try {
193 | const posts = await database.listDocuments(
194 | appwriteConfig.databaseId,
195 | appwriteConfig.postCollectionId,
196 | [Query.orderDesc('$createdAt'), Query.limit(20)]
197 | );
198 |
199 | if (!posts) {
200 | throw new Error('Post retrieval failed');
201 | }
202 |
203 | return posts;
204 | } catch (error) {
205 | console.error(error);
206 | }
207 | }
208 |
209 | export async function likePost(postId: string, likes: string[]) {
210 | try {
211 | const updatedPost = await database.updateDocument(
212 | appwriteConfig.databaseId,
213 | appwriteConfig.postCollectionId,
214 | postId,
215 | {likes}
216 | );
217 |
218 | if (!updatedPost) {
219 | throw new Error('Post update failed');
220 | }
221 |
222 | return updatedPost;
223 | } catch (error) {
224 | console.error(error);
225 | }
226 | }
227 |
228 | export async function getInfinitePosts({pageParam = 0}: {pageParam: number}) {
229 | try {
230 | const queries: any[] = [Query.orderDesc('$updatedAt'), Query.limit(10)];
231 |
232 | if (pageParam) {
233 | queries.push(Query.cursorAfter(pageParam.toString()));
234 | }
235 |
236 | const posts = await database.listDocuments(
237 | appwriteConfig.databaseId,
238 | appwriteConfig.postCollectionId,
239 | queries
240 | );
241 |
242 | if (!posts) {
243 | throw new Error('Post retrieval failed');
244 | }
245 |
246 | return posts;
247 | } catch (error) {
248 | console.error(error);
249 | }
250 | }
251 |
252 | export async function searchPosts(searchTerm: string) {
253 | try {
254 | const posts = await database.listDocuments(
255 | appwriteConfig.databaseId,
256 | appwriteConfig.postCollectionId,
257 | [Query.search('caption', searchTerm)]
258 | );
259 |
260 | if (!posts) {
261 | throw new Error('Post retrieval failed');
262 | }
263 |
264 | return posts;
265 | } catch (error) {
266 | console.log(error);
267 | }
268 | }
269 |
270 | export async function getUserPosts(userId?: string) {
271 | if (!userId) {
272 | throw new Error('Missing user ID');
273 | }
274 |
275 | try {
276 | const posts = await database.listDocuments(
277 | appwriteConfig.databaseId,
278 | appwriteConfig.postCollectionId,
279 | [Query.equal('creator', userId), Query.orderDesc('$createdAt')]
280 | );
281 |
282 | if (!posts) {
283 | throw new Error('Post retrieval failed');
284 | }
285 |
286 | return posts;
287 | } catch (error) {
288 | console.error(error);
289 | }
290 | }
291 |
--------------------------------------------------------------------------------
/public/assets/images/logo-black.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------